From 63337ef25379769ce9c14ff762d6ad99acd2a3c4 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 12 Aug 2026 18:47:12 +0530 Subject: [PATCH 1/3] Revoke the access token before completing sign out Adds tokenLifecycle.revokeToken.revokeOnSignOut config (default true) so signOut() revokes the access token at the OP's revocation_endpoint before clearing the local session. Revocation is best-effort: failures don't block sign out since the local session is cleared regardless. --- .../browser/src/ThunderIDBrowserClient.ts | 13 +++++++++++ packages/javascript/src/models/config.ts | 23 ++++++++++++++++--- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 4cb77df3..94ddae71 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -369,6 +369,19 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const sm = this.getStorageManager(); const config = await (sm as any).getConfigData(); + // Revoke the access token at the OP before ending the session. Best-effort: revocation can + // fail (no revocation_endpoint advertised, network error, non-200 response) without blocking + // sign out, since the local session must be cleared regardless. Set + // tokenLifecycle.revokeToken.revokeOnSignOut to false to skip this and only clear the local + // session. + if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut !== false) { + try { + await this.revokeAccessToken(sessionId); + } catch (error) { + logger.debug('Could not revoke the access token before signing out.', error); + } + } + // 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; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 03060bc2..0fdf57cc 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -214,8 +214,8 @@ export interface BaseConfig 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. @@ -429,6 +429,24 @@ export interface BaseConfig 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. + * + * Enabled by default. Revocation is best-effort: if it fails (no `revocation_endpoint` + * advertised, network error, non-200 response), sign out still proceeds with a + * local-only session clear. Set to `false` to skip revocation and only clear the local + * session. + * + * @default true + */ + revokeOnSignOut?: boolean; + }; }; /** @@ -602,7 +620,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; /** From edac4579b6643caf3429d847ac5f437a92a05619 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 12 Aug 2026 19:11:07 +0530 Subject: [PATCH 2/3] Resolve logout URL before revoking token, bound revocation request, fix doc Extracts a revocation-request-only core method so signOut() can resolve the RP-Initiated Logout URL (needs the ID token) before revoking the access token, instead of the public revokeAccessToken() override clearing the session prematurely. Bounds the revocation fetch with a timeout so a stalled revocation_endpoint can't block signOut() indefinitely, and corrects the revokeOnSignOut JSDoc to describe the actual fallback behavior. --- .../browser/src/ThunderIDBrowserClient.ts | 60 ++++++++++--------- .../src/ThunderIDJavaScriptClient.ts | 16 ++++- packages/javascript/src/models/config.ts | 5 +- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 94ddae71..b44fcf48 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -369,28 +369,18 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC const sm = this.getStorageManager(); const config = await (sm as any).getConfigData(); - // Revoke the access token at the OP before ending the session. Best-effort: revocation can - // fail (no revocation_endpoint advertised, network error, non-200 response) without blocking - // sign out, since the local session must be cleared regardless. Set - // tokenLifecycle.revokeToken.revokeOnSignOut to false to skip this and only clear the local - // session. - if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut !== false) { - try { - await this.revokeAccessToken(sessionId); - } catch (error) { - logger.debug('Could not revoke the access token before signing out.', error); - } - } - // 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 { @@ -401,20 +391,34 @@ class ThunderIDBrowserClient 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. 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. Set tokenLifecycle.revokeToken.revokeOnSignOut + // to false to skip this. + if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut !== false) { + 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); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index fbf5b03c..ee601857 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -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> = { enablePKCE: true, responseMode: 'query', @@ -880,7 +882,12 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { }; } - protected async revokeAccessToken(userId?: string): Promise { + /** + * 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 { const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint; const configData = await this.configProvider(); @@ -910,6 +917,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { 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( @@ -927,6 +935,12 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { ); } + return response; + } + + protected async revokeAccessToken(userId?: string): Promise { + const response = await this.requestAccessTokenRevocation(userId); + this.authHelper.clearSession(userId); return response; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 0fdf57cc..a70b9b62 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -439,9 +439,8 @@ export interface BaseConfig extends WithPreferences, WithExtensions * clearing the local session and completing sign out. * * Enabled by default. Revocation is best-effort: if it fails (no `revocation_endpoint` - * advertised, network error, non-200 response), sign out still proceeds with a - * local-only session clear. Set to `false` to skip revocation and only clear the local - * session. + * advertised, network error, non-200 response), sign out still proceeds unaffected. Set to + * `false` to skip revocation; sign out then continues according to `rpInitiatedLogout`. * * @default true */ From 7087cff34b3be6da9d4121883c1b2f62b72202eb Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 12 Aug 2026 19:29:38 +0530 Subject: [PATCH 3/3] Make revokeOnSignOut opt-in, disabled by default --- packages/browser/src/ThunderIDBrowserClient.ts | 8 ++++---- packages/javascript/src/models/config.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index b44fcf48..0ddc7554 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -393,13 +393,13 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } } - // Revoke the access token at the OP before clearing the session. Best-effort: revocation can + // 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. Set tokenLifecycle.revokeToken.revokeOnSignOut - // to false to skip this. - if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut !== false) { + // ahead of the RP-Initiated Logout URL resolution. + if (config?.tokenLifecycle?.revokeToken?.revokeOnSignOut === true) { try { await this.requestAccessTokenRevocation(sessionId); } catch (error) { diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index a70b9b62..594b039b 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -438,11 +438,11 @@ export interface BaseConfig extends WithPreferences, WithExtensions * Whether `signOut()` revokes the access token at the OP's `revocation_endpoint` before * clearing the local session and completing sign out. * - * Enabled by default. Revocation is best-effort: if it fails (no `revocation_endpoint` - * advertised, network error, non-200 response), sign out still proceeds unaffected. Set to - * `false` to skip revocation; sign out then continues according to `rpInitiatedLogout`. + * 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 true + * @default false */ revokeOnSignOut?: boolean; };