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
41 changes: 38 additions & 3 deletions packages/browser/src/ThunderIDBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,19 +372,54 @@ class ThunderIDBrowserClient<T = BrowserAuthConfig> extends ThunderIDJavaScriptC
afterSignOutParam?: (url: string) => void,
): Promise<string> {
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;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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);
Expand Down
24 changes: 15 additions & 9 deletions packages/javascript/src/ThunderIDJavaScriptClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,15 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {
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<void> {
return this.authHelper.clearSession(sessionId);
}

public async setSession(sessionData: Record<string, unknown>, sessionId?: string): Promise<void> {
await this.storageManager.setSessionData(sessionData, sessionId);
}
Expand Down Expand Up @@ -623,16 +632,13 @@ class ThunderIDJavaScriptClient<T = Config> implements ThunderIDClient<T> {

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 ?? '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,71 @@ describe('ThunderIDJavaScriptClient', () => {
}
});
});

describe('getSignOutUrl()', () => {
const END_SESSION = 'https://example.com/oauth2/logout';

async function initForSignOut(
overrides: Record<string, unknown>,
meta: Record<string, unknown> = {end_session_endpoint: END_SESSION},
): Promise<ThunderIDJavaScriptClient> {
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);
});
});
});
23 changes: 21 additions & 2 deletions packages/javascript/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,30 @@ export interface BaseConfig<T = unknown> 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.
Expand Down
Loading