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
19 changes: 11 additions & 8 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,18 +393,21 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
}
}

// 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
// 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
// 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) {
// 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) => {
logger.debug('Could not revoke the access token before signing out.', error);
}
});
}

if (signOutUrl) {
Expand Down
96 changes: 96 additions & 0 deletions packages/browser/src/__tests__/ThunderIDBrowserClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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<string, unknown> = {}): Promise<ThunderIDBrowserClient> {
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<void>((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();
});
});
10 changes: 8 additions & 2 deletions packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,8 +886,12 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
* 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): Promise<Response> {
protected async requestAccessTokenRevocation(userId?: string, accessToken?: string): Promise<Response> {
const revokeTokenEndpoint: string | undefined = (await this.oidcProviderMetaDataProvider()).revocation_endpoint;
const configData = await this.configProvider();

Expand All @@ -899,9 +903,11 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
);
}

const resolvedAccessToken = accessToken ?? (await this.storageManager.getSessionData(userId)).access_token;

const body: string[] = [
`client_id=${configData.clientId}`,
`token=${(await this.storageManager.getSessionData(userId)).access_token}`,
`token=${resolvedAccessToken}`,
'token_type_hint=access_token',
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,4 +488,61 @@ describe('ThunderIDJavaScriptClient', () => {
expect(url.searchParams.has('client_id')).toBe(false);
});
});

describe('requestAccessTokenRevocation()', () => {
const REVOCATION_ENDPOINT = 'https://example.com/oauth2/revoke';

async function initForRevocation(): Promise<ThunderIDJavaScriptClient> {
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',
});
});
});
});
Loading