diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 73f15ec7..0ddc7554 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -393,21 +393,18 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC } } - // Revoke the access token at the OP. Disabled by default; set - // tokenLifecycle.revokeToken.revokeOnSignOut to true to enable. Fire-and-forget: not awaited, so a - // slow or unreachable revocation_endpoint can't delay the redirect below. Best-effort: revocation - // can fail (no revocation_endpoint advertised, network error, non-200 response, or a stalled - // request) without affecting sign out, since the local session is cleared regardless. Uses the + // 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) { - // Snapshot the access token now, before firing the revocation request without awaiting it — - // clearSession(Async) below can otherwise remove it from storage before this request reads it. - const accessTokenToRevoke = (await sm.getSessionData(sessionId))?.access_token; - - this.requestAccessTokenRevocation(sessionId, accessTokenToRevoke).catch((error) => { + try { + await this.requestAccessTokenRevocation(sessionId); + } catch (error) { logger.debug('Could not revoke the access token before signing out.', error); - }); + } } if (signOutUrl) { diff --git a/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts b/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts deleted file mode 100644 index 1fac582c..00000000 --- a/packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2026 The ThunderID Authors -// SPDX-License-Identifier: Apache-2.0 - -import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'; -import ThunderIDBrowserClient from '../ThunderIDBrowserClient'; - -vi.mock('../utils/navigate', () => ({default: vi.fn()})); - -const BASE_CONFIG = { - baseUrl: 'https://example.com', - clientId: 'test-client', - rpInitiatedLogout: false, - signInUrl: 'https://example.com/sign-in', - storage: 'browserMemory', -} as any; - -const REVOCATION_ENDPOINT = 'https://example.com/oauth2/revoke'; - -async function initClient(overrides: Record = {}): Promise { - const client = new ThunderIDBrowserClient(); - await client.initialize({...BASE_CONFIG, ...overrides}); - const sm = (client as any).getStorageManager(); - await sm.setOIDCProviderMetaData({revocation_endpoint: REVOCATION_ENDPOINT}); - await sm.setTemporaryDataParameter('op_config_initiated', true); - await sm.setSessionData({access_token: 'stored-access-token'}); - - return client; -} - -function mockFetchOnce(ok = true, status = 200): {resolve: () => void} { - let resolveFetch: () => void = () => {}; - const pending = new Promise((resolve) => { - resolveFetch = resolve; - }); - - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation( - () => - new Promise((resolve) => { - pending.then(() => - resolve({ - json: () => Promise.resolve({}), - ok, - status, - statusText: ok ? 'OK' : 'Bad Request', - }), - ); - }), - ), - ); - - return {resolve: resolveFetch}; -} - -describe('ThunderIDBrowserClient signOut()', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('completes sign out without waiting for a slow revocation_endpoint to respond', async () => { - const client = await initClient({tokenLifecycle: {revokeToken: {revokeOnSignOut: true}}}); - // Never resolved during this test — if signOut() awaited the revocation response, it would hang. - mockFetchOnce(); - - const afterSignOutUrl = await client.signOut(); - - expect(afterSignOutUrl).toBe(window.location.origin); - await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)); - }); - - it('sends the access token captured before session clearing, not a post-clear read', async () => { - const client = await initClient({tokenLifecycle: {revokeToken: {revokeOnSignOut: true}}}); - const {resolve} = mockFetchOnce(); - - await client.signOut(); - resolve(); - await vi.waitFor(() => expect((fetch as any).mock.calls.length).toBe(1)); - - const [, requestInit] = (fetch as any).mock.calls[0]; - expect(requestInit.body).toContain('token=stored-access-token'); - }); - - it('does not call the revocation endpoint when revokeOnSignOut is not enabled', async () => { - const client = await initClient(); - mockFetchOnce(); - - await client.signOut(); - - expect(fetch).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 2c43f009..ee601857 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -886,12 +886,8 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { * 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. - * - * `accessToken` can be passed explicitly to snapshot it ahead of time (e.g. before firing this off - * without awaiting it, so a concurrent session clear can't race the token being read from storage). - * When omitted, it's read from storage at call time. */ - protected async requestAccessTokenRevocation(userId?: string, accessToken?: string): Promise { + protected async requestAccessTokenRevocation(userId?: string): Promise { const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint; const configData = await this.configProvider(); @@ -903,11 +899,9 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { ); } - const resolvedAccessToken = accessToken ?? (await this.storageManager.getSessionData(userId)).access_token; - const body: string[] = [ `client_id=${configData.clientId}`, - `token=${resolvedAccessToken}`, + `token=${(await this.storageManager.getSessionData(userId)).access_token}`, 'token_type_hint=access_token', ]; diff --git a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts index edbd3129..426ce73f 100644 --- a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts +++ b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts @@ -488,61 +488,4 @@ describe('ThunderIDJavaScriptClient', () => { expect(url.searchParams.has('client_id')).toBe(false); }); }); - - describe('requestAccessTokenRevocation()', () => { - const REVOCATION_ENDPOINT = 'https://example.com/oauth2/revoke'; - - async function initForRevocation(): Promise { - const client = new ThunderIDJavaScriptClient(store, {} as any); - await client.initialize(BASE_CONFIG); - const sm = (client as any).storageManager; - await sm.setOIDCProviderMetaData({revocation_endpoint: REVOCATION_ENDPOINT}); - await sm.setTemporaryDataParameter('op_config_initiated', true); - await sm.setSessionData({access_token: 'stored-access-token'}); - return client; - } - - it('reads the access token from storage when no override is passed', async () => { - const client = await initForRevocation(); - mockFetchOnce({}, true, 200); - - await (client as any).requestAccessTokenRevocation(); - - const [, requestInit] = (fetch as any).mock.calls[0]; - expect(requestInit.body).toContain('token=stored-access-token'); - }); - - it('uses the passed accessToken instead of reading storage, so a concurrent session clear cannot race it', async () => { - const client = await initForRevocation(); - // Simulate the session having already been cleared by the time the request body is built. - await (client as any).storageManager.setSessionData({access_token: undefined}); - mockFetchOnce({}, true, 200); - - await (client as any).requestAccessTokenRevocation(undefined, 'snapshotted-access-token'); - - const [, requestInit] = (fetch as any).mock.calls[0]; - expect(requestInit.body).toContain('token=snapshotted-access-token'); - }); - - it('throws when the OP advertises no revocation_endpoint', async () => { - const client = new ThunderIDJavaScriptClient(store, {} as any); - await client.initialize(BASE_CONFIG); - const sm = (client as any).storageManager; - await sm.setOIDCProviderMetaData({token_endpoint: 'https://example.com/oauth2/token'}); - await sm.setTemporaryDataParameter('op_config_initiated', true); - - await expect((client as any).requestAccessTokenRevocation()).rejects.toMatchObject({ - code: 'JS-AUTH_CORE-RAT3-NF01', - }); - }); - - it('throws when the revocation request receives a non-200 response', async () => { - const client = await initForRevocation(); - mockFetchOnce({error: 'invalid_token'}, false, 400); - - await expect((client as any).requestAccessTokenRevocation()).rejects.toMatchObject({ - code: 'JS-AUTH_CORE-RAT3-HE03', - }); - }); - }); });