From bd78aff1b5952e23526ac4a87e6a505d558ba222 Mon Sep 17 00:00:00 2001 From: Maduranga Siriwardena Date: Tue, 21 Jul 2026 19:28:04 +0530 Subject: [PATCH] Add OIDC RP-Initiated Logout support to signOut() signOut() performs OIDC RP-Initiated Logout by default: it resolves the sign-out URL (post_logout_redirect_uri, plus id_token_hint when an ID token is available, else client_id) before clearing the local session, then redirects to the OP's end_session_endpoint. 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, or sendIdTokenInLogoutRequest: false to always send client_id. Add clearSessionAsync so the local clear can complete before the redirect. --- .../browser/src/ThunderIDBrowserClient.ts | 41 +++++++++++- .../src/ThunderIDJavaScriptClient.ts | 24 ++++--- .../ThunderIDJavaScriptClient.test.ts | 67 +++++++++++++++++++ packages/javascript/src/models/config.ts | 23 ++++++- 4 files changed, 141 insertions(+), 14 deletions(-) diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 6456dd2..e9f3166 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -372,19 +372,54 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC afterSignOutParam?: (url: string) => void, ): Promise { let afterSignOut: ((url: string) => void) | undefined; + let sessionId: string | undefined; if (typeof sessionIdOrAfterSignOut === 'function') { afterSignOut = sessionIdOrAfterSignOut; } else if (typeof sessionIdOrAfterSignOut === 'string') { + sessionId = sessionIdOrAfterSignOut; afterSignOut = afterSignOutParam; } const sm = this.getStorageManager(); const config = await (sm as any).getConfigData(); - // TEMPORARY: Handle sign-out by clearing the session and navigating back to sign-in, - // until the OIDC end-session flow is fully supported. - this.clearSession(); + // 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. + 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); + + if (!signOutUrl) { + try { + signOutUrl = await this.getSignOutUrl(sessionId); + } catch (error) { + // end_session_endpoint absent or ID token unavailable — fall through to local sign out. + logger.debug('Could not build the RP-initiated logout URL; falling back to a local sign out.', error); + 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; + } + } + + // 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); if (config?.signInUrl) { navigate(config.signInUrl); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 67e9c9f..f53eac6 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -189,6 +189,15 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { this.authHelper.clearSession(sessionId); } + /** + * Async variant of {@link clearSession} that resolves once the session data has been removed from + * storage. Await this when the clear must complete before proceeding (for example, before a + * full-page sign-out redirect); {@link clearSession} remains a fire-and-forget convenience. + */ + protected clearSessionAsync(sessionId?: string): Promise { + return this.authHelper.clearSession(sessionId); + } + public async setSession(sessionData: Record, sessionId?: string): Promise { await this.storageManager.setSessionData(sessionData, sessionId); } @@ -623,16 +632,13 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { queryParams.set('post_logout_redirect_uri', callbackURL); - if (configData.sendIdTokenInLogoutRequest) { - const idToken: string = (await this.storageManager.getSessionData(userId))?.id_token; + // Include id_token_hint by default when an ID token is available: OIDC RP-Initiated Logout + // recommends it, and some OPs (including ThunderID) require it alongside post_logout_redirect_uri. + // Fall back to client_id when no ID token is available. Set sendIdTokenInLogoutRequest: false to + // always send client_id instead. + const idToken: string = (await this.storageManager.getSessionData(userId))?.id_token; - if (!idToken || idToken.trim().length === 0) { - throw new ThunderIDAuthException( - 'JS-AUTH_CORE-GSOU-NF02', - 'ID token not found.', - 'No ID token could be found. Either the session information is lost or you have not signed in.', - ); - } + if (configData.sendIdTokenInLogoutRequest !== false && idToken && idToken.trim().length > 0) { queryParams.set('id_token_hint', idToken); } else { queryParams.set('client_id', configData.clientId ?? ''); diff --git a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts index a49371f..f0124ed 100644 --- a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts +++ b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts @@ -436,4 +436,71 @@ describe('ThunderIDJavaScriptClient', () => { } }); }); + + describe('getSignOutUrl()', () => { + const END_SESSION = 'https://example.com/oauth2/logout'; + + async function initForSignOut( + overrides: Record, + meta: Record = {end_session_endpoint: END_SESSION}, + ): Promise { + const client = new ThunderIDJavaScriptClient(store, {} as any); + await client.initialize({...BASE_CONFIG, afterSignOutUrl: 'https://example.com/console', ...overrides}); + const sm = (client as any).storageManager; + await sm.setOIDCProviderMetaData(meta); + await sm.setTemporaryDataParameter('op_config_initiated', true); + return client; + } + + it('includes id_token_hint and post_logout_redirect_uri when sendIdTokenInLogoutRequest is true', async () => { + const client = await initForSignOut({sendIdTokenInLogoutRequest: true}); + await (client as any).storageManager.setSessionData({id_token: 'test-id-token'}); + + const url = new URL(await (client as any).getSignOutUrl()); + + expect(`${url.origin}${url.pathname}`).toBe(END_SESSION); + expect(url.searchParams.get('id_token_hint')).toBe('test-id-token'); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://example.com/console'); + expect(url.searchParams.get('state')).toBe('sign_out_success'); + expect(url.searchParams.has('client_id')).toBe(false); + }); + + it('sends client_id instead of id_token_hint when sendIdTokenInLogoutRequest is false', async () => { + const client = await initForSignOut({sendIdTokenInLogoutRequest: false}); + + const url = new URL(await (client as any).getSignOutUrl()); + + expect(url.searchParams.get('client_id')).toBe('test-client'); + expect(url.searchParams.has('id_token_hint')).toBe(false); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://example.com/console'); + }); + + it('throws when the OP advertises no end_session_endpoint', async () => { + const client = await initForSignOut( + {sendIdTokenInLogoutRequest: true}, + {token_endpoint: 'https://example.com/oauth2/token'}, + ); + + await expect((client as any).getSignOutUrl()).rejects.toMatchObject({code: 'JS-AUTH_CORE-GSOU-NF01'}); + }); + + it('falls back to client_id when no ID token is available', async () => { + const client = await initForSignOut({sendIdTokenInLogoutRequest: true}); + + const url = new URL(await (client as any).getSignOutUrl()); + + expect(url.searchParams.get('client_id')).toBe('test-client'); + expect(url.searchParams.has('id_token_hint')).toBe(false); + }); + + it('sends id_token_hint by default (no explicit flag) when an ID token is available', async () => { + const client = await initForSignOut({}); + await (client as any).storageManager.setSessionData({id_token: 'test-id-token'}); + + const url = new URL(await (client as any).getSignOutUrl()); + + expect(url.searchParams.get('id_token_hint')).toBe('test-id-token'); + expect(url.searchParams.has('client_id')).toBe(false); + }); + }); }); diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 9545fea..7a9af6e 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -90,11 +90,30 @@ export interface BaseConfig extends WithPreferences, WithExtensions sendCookiesInRequests?: boolean; /** - * Whether to include the ID token as a hint in the logout request. - * When `true`, the `id_token_hint` parameter is sent to the end-session endpoint. + * Whether to include the ID token as `id_token_hint` in the RP-initiated logout request. + * + * By default the `id_token_hint` parameter is sent to the `end_session_endpoint` whenever an ID + * token is available (recommended by OIDC RP-Initiated Logout, and required by some OPs alongside + * `post_logout_redirect_uri`), falling back to `client_id` otherwise. Set to `false` to always + * send `client_id` instead of `id_token_hint`. + * + * @default true */ sendIdTokenInLogoutRequest?: boolean; + /** + * Whether `signOut()` performs OIDC RP-Initiated Logout at the OP's `end_session_endpoint`. + * + * Enabled by default: `signOut()` clears the local session and redirects the browser to the + * `end_session_endpoint` so the OP can terminate its own session, falling back to a local-only + * sign out when no `end_session_endpoint` is advertised or the sign-out URL cannot be built. The + * request includes `id_token_hint` automatically when an ID token is available (see + * `sendIdTokenInLogoutRequest`). Set to `false` to force a local-only sign out. + * + * @default true + */ + rpInitiatedLogout?: boolean; + /** * Optional URL where the authorization server should redirect after authentication. * This must match one of the allowed redirect URIs configured in your IdP.