Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,14 +370,17 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
const config = await (sm as any).getConfigData();

// OIDC RP-Initiated Logout: end the session at the OP's end_session_endpoint. The sign-out URL
// (carrying id_token_hint/client_id + post_logout_redirect_uri) is resolved before the local
// session is cleared, so the ID token used for the hint is still available. This is the default;
// it falls back to a local-only sign out when no end_session_endpoint is advertised or the URL
// cannot be built. Set rpInitiatedLogout: false to force a local-only sign out.
// (carrying id_token_hint/client_id + post_logout_redirect_uri) is resolved before the access
// token is revoked or the local session is cleared, so the ID token used for the hint is still
// available. This is the default; it falls back to a local-only sign out when no
// end_session_endpoint is advertised or the URL cannot be built. Set rpInitiatedLogout: false to
// force a local-only sign out.
let signOutUrl = '';

if (config?.rpInitiatedLogout !== false) {
// A cached URL is stored per client at token exchange; when a specific session is targeted, build
// a fresh URL instead so the id_token_hint matches that session.
let signOutUrl: string = sessionId ? '' : SPAUtils.getSignOutUrl(config.clientId, this._browserInstanceId);
signOutUrl = sessionId ? '' : SPAUtils.getSignOutUrl(config.clientId, this._browserInstanceId);

if (!signOutUrl) {
try {
Expand All @@ -388,20 +391,34 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
signOutUrl = '';
}
}
}

if (signOutUrl) {
// Await the clear so local tokens are gone before navigating away; clearSession() is otherwise
// fire-and-forget and could be cut short by the redirect.
await this.clearSessionAsync(sessionId);
// Notify the caller before navigating away, mirroring the local-only path below.
afterSignOut?.(signOutUrl);
location.href = signOutUrl;
await SPAUtils.waitTillPageRedirect();

return signOutUrl;
// Revoke the access token at the OP before clearing the session. Disabled by default; set
// tokenLifecycle.revokeToken.revokeOnSignOut to true to enable. Best-effort: revocation can
// fail (no revocation_endpoint advertised, network error, non-200 response, or a stalled request)
// without blocking sign out, since the local session must be cleared regardless. Uses the
// request-only core method so the session (and the ID token read above) isn't cleared twice or
// ahead of the RP-Initiated Logout URL resolution.
if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut === true) {
try {
await this.requestAccessTokenRevocation(sessionId);
} catch (error) {
logger.debug('Could not revoke the access token before signing out.', error);
}
}

if (signOutUrl) {
// Await the clear so local tokens are gone before navigating away; clearSession() is otherwise
// fire-and-forget and could be cut short by the redirect.
await this.clearSessionAsync(sessionId);
// Notify the caller before navigating away, mirroring the local-only path below.
afterSignOut?.(signOutUrl);
location.href = signOutUrl;
await SPAUtils.waitTillPageRedirect();

return signOutUrl;
}

// Local-only sign out: clear the session and navigate back to sign-in. Used when RP-initiated
// logout is disabled, or as a fallback when the OP advertises no end_session_endpoint.
this.clearSession(sessionId);
Expand Down
16 changes: 15 additions & 1 deletion packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import processOpenIDScopes from './utils/processOpenIDScopes';

const WELL_KNOWN_PATH = '/.well-known/openid-configuration';

const REVOKE_ACCESS_TOKEN_REQUEST_TIMEOUT_MS = 10_000;

const DEFAULT_CONFIG: Partial<AuthClientConfig<unknown>> = {
enablePKCE: true,
responseMode: 'query',
Expand Down Expand Up @@ -880,7 +882,12 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
};
}

protected async revokeAccessToken(userId?: string): Promise<Response | boolean> {
/**
* Sends the access token revocation request to the OP's `revocation_endpoint`. Unlike
* {@link revokeAccessToken}, this does not clear the local session, so callers that need to read
* session data (e.g. the ID token for RP-Initiated Logout) after revoking can do so.
*/
protected async requestAccessTokenRevocation(userId?: string): Promise<Response> {
const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint;
const configData = await this.configProvider();

Expand Down Expand Up @@ -910,6 +917,7 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
credentials: configData.sendCookiesInRequests ? 'include' : 'same-origin',
headers: {Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded'},
method: 'POST',
signal: AbortSignal.timeout(REVOKE_ACCESS_TOKEN_REQUEST_TIMEOUT_MS),
});
} catch (error: any) {
throw new ThunderIDAuthException(
Expand All @@ -927,6 +935,12 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
);
}

return response;
}

protected async revokeAccessToken(userId?: string): Promise<Response | boolean> {
const response = await this.requestAccessTokenRevocation(userId);

this.authHelper.clearSession(userId);

return response;
Expand Down
22 changes: 19 additions & 3 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,8 @@ export interface BaseConfig<T = unknown> extends WithPreferences, WithExtensions
* by default derived by concatenating `baseUrl` with a fixed path (e.g. `{baseUrl}/flow/execute`).
* These do not participate in OIDC discovery.
*
* Split these two groups when the OAuth authorization server (IdP) and the Thunder resource
* server are different hosts — for example, when two Thunder instances are connected as trusted
* Split these two groups when the OAuth authorization server (IdP) and the ThunderID resource
* server are different hosts — for example, when two ThunderID instances are connected as trusted
* issuers. Point `baseUrl` (and hence the OAuth/discovery endpoints) at the authorization server,
* and override the resource-server endpoints to target the resource server that actually owns the
* users and flows.
Expand Down Expand Up @@ -429,6 +429,23 @@ export interface BaseConfig<T = unknown> extends WithPreferences, WithExtensions
*/
autoRefresh?: boolean;
};

/**
* Configuration for token revocation behavior.
*/
revokeToken?: {
/**
* Whether `signOut()` revokes the access token at the OP's `revocation_endpoint` before
* clearing the local session and completing sign out.
*
* Disabled by default. Set to `true` to enable. Revocation is best-effort: if it fails (no
* `revocation_endpoint` advertised, network error, non-200 response), sign out still
* proceeds unaffected, continuing according to `rpInitiatedLogout`.
*
* @default false
*/
revokeOnSignOut?: boolean;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

/**
Expand Down Expand Up @@ -602,7 +619,6 @@ export interface Preferences {
i18n?: I18nPreferences;
/**
* Whether to resolve the theme from the Flow Meta API (GET /flow/meta).
* @remarks This is only applicable when using platform `ThunderID V2` (Thunder).
*/
resolveFromMeta?: boolean;
/**
Expand Down
Loading