diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 4cb77df3..0ddc7554 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -370,14 +370,17 @@ class ThunderIDBrowserClient 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 { @@ -388,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. 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); 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 03060bc2..594b039b 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,23 @@ 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. + * + * 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; + }; }; /** @@ -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; /**