feat: sign out of the identity provider too when signing out of a browser sign-in

This commit is contained in:
Linus Rath
2026-10-31 13:55:47 +01:00
parent 2fe8366938
commit 5a301d62dc
7 changed files with 270 additions and 2 deletions
@@ -0,0 +1,57 @@
/**
* Live check of ending the provider's session at sign-out (OIDC
* RP-Initiated Logout) against a real Stalwart. Opt-in like the other live
* tests:
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
* npx vitest run src/api/__tests__/live-stalwart-end-session.test.ts
*
* 1.0 announces an end_session_endpoint and hands out an id token with the
* tokens; 0.16 has neither, and sign-out opens nothing there. The grant the
* run makes is revoked again.
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async () => undefined),
getItemAsync: vi.fn(async () => null),
deleteItemAsync: vi.fn(async () => undefined),
}));
import { buildEndSessionUrl, discoverOAuthMetadata, revokeRefreshToken } from '../../lib/oauth-native';
import { exchangePasswordForTokens } from '../../lib/totp-login';
const SERVER = (process.env.BULWARK_LIVE_JMAP ?? '').replace(/\/+$/, '');
const USER = process.env.BULWARK_LIVE_USER ?? '';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && USER && PASS ? describe : describe.skip;
live('ending the provider session at sign-out (live)', () => {
it('logs out with the id token where the server announces an end_session_endpoint', async () => {
const metadata = await discoverOAuthMetadata(SERVER);
expect(metadata).not.toBeNull();
const tokens = await exchangePasswordForTokens(SERVER, USER, PASS);
try {
if (!metadata!.end_session_endpoint) {
// 0.16: nothing to open.
expect(buildEndSessionUrl(SERVER, metadata!, tokens)).toBeNull();
return;
}
// 1.0: the token answer carries an id token (a JWT), kept for the hint.
expect(tokens.idToken?.split('.')).toHaveLength(3);
const target = buildEndSessionUrl(SERVER, metadata!, tokens);
expect(target).not.toBeNull();
const url = new URL(target!.url);
expect(url.searchParams.get('id_token_hint')).toBe(tokens.idToken);
const response = await fetch(target!.url, { redirect: 'manual' });
// Signed out: a page saying so, or back to where the app asked.
expect([200, 302, 303]).toContain(response.status);
if (response.status !== 200) {
expect(response.headers.get('location')).toBe(url.searchParams.get('post_logout_redirect_uri'));
}
} finally {
await revokeRefreshToken(SERVER, tokens);
}
});
});
+6
View File
@@ -59,6 +59,9 @@ export interface StoredCredentials {
tokenEndpoint?: string;
clientId?: string;
tokenSource?: OAuthTokenSource;
// The OIDC id token of a browser sign-in, for ending the provider's
// session at sign-out (lib/oauth-native endProviderSession).
idToken?: string;
}
/**
@@ -404,6 +407,7 @@ export class JMAPClient {
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
tokenSource: tokens.source,
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
};
const fetched = await this.fetchSession(baseUrl);
@@ -460,6 +464,7 @@ export class JMAPClient {
expiresAt: next.expiresAt,
tokenEndpoint: next.tokenEndpoint,
clientId: next.clientId,
idToken: next.idToken ?? base.idToken,
};
if (inUse) this.credentials = updated;
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(updated));
@@ -529,6 +534,7 @@ export class JMAPClient {
tokenEndpoint: creds.tokenEndpoint,
clientId: creds.clientId,
source: creds.tokenSource,
...(creds.idToken ? { idToken: creds.idToken } : {}),
};
}
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as WebBrowser from 'expo-web-browser';
// Sign-out ends the identity provider's own session too (OIDC RP-Initiated
// Logout, webmail #905): revoking the refresh token left the browser signed
// in at the provider, and the next browser sign-in went through unasked.
vi.mock('../client-cert', () => {
const fetchFn = vi.fn();
return {
secureFetch: fetchFn,
fetchWithoutForeignRedirect: fetchFn,
followedOnlyAllowedRedirects: () => true,
mayFollowRedirect: () => true,
};
});
import { fetchWithoutForeignRedirect } from '../client-cert';
import { buildEndSessionUrl, endProviderSession, loginWithPkce, type OAuthMetadata } from '../oauth-native';
import { refreshOAuthAccessToken, resetRefreshRotations, type OAuthTokens } from '../oauth';
import { DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID, REGISTERED_LOGOUT_REDIRECT_URI } from '../totp-login';
const fetchMock = fetchWithoutForeignRedirect as unknown as ReturnType<typeof vi.fn>;
const browser = WebBrowser.openAuthSessionAsync as ReturnType<typeof vi.fn>;
const SERVER = 'https://mail.example.org';
const DISCOVERY = {
issuer: SERVER,
authorization_endpoint: `${SERVER}/login`,
token_endpoint: `${SERVER}/auth/token`,
end_session_endpoint: `${SERVER}/auth/logout`,
};
const json = (body: unknown) => ({ ok: true, status: 200, url: '', json: async () => body, text: async () => JSON.stringify(body) });
function tokens(over: Partial<OAuthTokens> = {}): OAuthTokens {
return {
accessToken: 'at', refreshToken: 'rt', tokenEndpoint: `${SERVER}/auth/token`,
clientId: 'registered-client', source: 'native', idToken: 'id.token.sig', ...over,
};
}
beforeEach(() => {
vi.clearAllMocks();
resetRefreshRotations();
browser.mockResolvedValue({ type: 'dismiss' });
});
describe('buildEndSessionUrl', () => {
it('names the client, hints the id token and comes back to the app for a client the app registered', () => {
const target = buildEndSessionUrl(SERVER, DISCOVERY, tokens());
const url = new URL(target!.url);
expect(url.origin + url.pathname).toBe(`${SERVER}/auth/logout`);
expect(url.searchParams.get('client_id')).toBe('registered-client');
expect(url.searchParams.get('id_token_hint')).toBe('id.token.sig');
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
expect(target!.returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
});
it('sends no return address for a client someone else registered', () => {
for (const clientId of [DEFAULT_CLIENT_ID, MOBILE_CLIENT_ID]) {
const url = new URL(buildEndSessionUrl(SERVER, DISCOVERY, tokens({ clientId }))!.url);
expect(url.searchParams.get('post_logout_redirect_uri')).toBeNull();
expect(url.searchParams.get('client_id')).toBe(clientId);
}
});
it('skips a provider without an end_session_endpoint, or one on another host in the clear', () => {
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: undefined }, tokens())).toBeNull();
expect(buildEndSessionUrl(SERVER, { end_session_endpoint: 'http://idp.example.net/logout' }, tokens())).toBeNull();
// The server's own host over http is what the user signed in to.
expect(buildEndSessionUrl('http://10.10.10.62:8120', { end_session_endpoint: 'http://10.10.10.62:8120/auth/logout' }, tokens()))
.not.toBeNull();
});
});
describe('endProviderSession', () => {
it("opens the provider's logout page after a browser sign-in", async () => {
fetchMock.mockResolvedValue(json(DISCOVERY));
await endProviderSession(SERVER, tokens());
expect(browser).toHaveBeenCalledTimes(1);
const [url, returnUrl] = browser.mock.calls[0] as [string, string];
expect(new URL(url).searchParams.get('id_token_hint')).toBe('id.token.sig');
expect(returnUrl).toBe(REGISTERED_LOGOUT_REDIRECT_URI);
});
it('opens nothing for sign-ins that left no browser session, or a provider without logout', async () => {
fetchMock.mockResolvedValue(json(DISCOVERY));
await endProviderSession(SERVER, tokens({ source: 'totp' }));
await endProviderSession(SERVER, tokens({ source: 'pairing' }));
fetchMock.mockResolvedValue(json({ ...DISCOVERY, end_session_endpoint: undefined }));
await endProviderSession(SERVER, tokens());
expect(browser).not.toHaveBeenCalled();
});
it('never fails the sign-out', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
fetchMock.mockResolvedValue(json(DISCOVERY));
browser.mockRejectedValueOnce(new Error('another session is open'));
await expect(endProviderSession(SERVER, tokens())).resolves.toBeUndefined();
});
});
describe('the id token', () => {
it('is kept from the browser sign-in', async () => {
const meta: OAuthMetadata = { authorization_endpoint: `${SERVER}/authorize`, token_endpoint: `${SERVER}/auth/token` };
browser.mockImplementationOnce(async (authUrl: string) => {
const state = new URL(authUrl).searchParams.get('state');
return { type: 'success', url: `bulwarkmobile://auth/callback?code=c&state=${state}` };
});
fetchMock.mockResolvedValue(json({ access_token: 'at', refresh_token: 'rt', expires_in: 300, id_token: 'id.token.sig' }));
const got = await loginWithPkce(SERVER, meta, { clientId: 'c1' });
expect(got.idToken).toBe('id.token.sig');
});
it('outlives a refresh that hands out none', async () => {
fetchMock.mockResolvedValue(json({ access_token: 'at2', refresh_token: 'rt2', expires_in: 300 }));
const next = await refreshOAuthAccessToken(tokens());
expect(next.idToken).toBe('id.token.sig');
});
});
+66
View File
@@ -18,6 +18,9 @@ import {
generateCodeVerifier,
resolveLoginClient,
DEFAULT_CLIENT_ID,
MOBILE_CLIENT_ID,
REGISTERED_LOGOUT_REDIRECT_URI,
REGISTERED_REDIRECT_URI,
} from './totp-login';
import { randomHex } from './random';
import { NetworkError } from '../api/jmap-client';
@@ -27,6 +30,7 @@ export interface OAuthMetadata {
authorization_endpoint: string;
token_endpoint: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
registration_endpoint?: string;
scopes_supported?: string[];
prompt_values_supported?: string[];
@@ -264,6 +268,7 @@ export async function loginWithPkce(
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
if (!data.access_token) throw new HandoffError('Token response missing access_token');
return {
@@ -273,9 +278,70 @@ export async function loginWithPkce(
tokenEndpoint: metadata.token_endpoint,
clientId,
source: 'native',
// Kept for ending the provider's session at sign-out (endProviderSession).
...(typeof data.id_token === 'string' && data.id_token ? { idToken: data.id_token } : {}),
};
}
/**
* The provider's logout URL for a sign-out (OIDC RP-Initiated Logout 1.0;
* webmail lib/oauth/end-session.ts buildEndSessionUrl), or null when the
* provider advertises no end_session_endpoint or an unusable one.
*
* `client_id` always goes along, `id_token_hint` when the sign-in's id token
* was kept. The browser carries the id token there, so the endpoint must be
* https, or on the mail server's own host, which the app already talks to in
* the clear when the user chose that. `post_logout_redirect_uri` is sent only
* for a client the app registered itself, with that URI on file: a provider
* refuses the whole logout for one it does not have.
*/
export function buildEndSessionUrl(
serverUrl: string,
metadata: Pick<OAuthMetadata, 'end_session_endpoint'>,
tokens: Pick<OAuthTokens, 'clientId' | 'idToken'>,
): { url: string; returnUrl: string } | null {
const endpoint = metadata.end_session_endpoint;
if (typeof endpoint !== 'string' || !/^https?:\/\//i.test(endpoint)) return null;
if (!/^https:\/\//i.test(endpoint) && !sameHost(endpoint, serverUrl)) return null;
const registered = tokens.clientId !== DEFAULT_CLIENT_ID && tokens.clientId !== MOBILE_CLIENT_ID;
const params = new URLSearchParams({ client_id: tokens.clientId });
if (tokens.idToken) params.set('id_token_hint', tokens.idToken);
if (registered) params.set('post_logout_redirect_uri', REGISTERED_LOGOUT_REDIRECT_URI);
return {
url: `${endpoint}${endpoint.includes('?') ? '&' : '?'}${params.toString()}`,
// The browser session closes when the provider sends it back to the app.
returnUrl: registered ? REGISTERED_LOGOUT_REDIRECT_URI : REGISTERED_REDIRECT_URI,
};
}
let endSessionQueue: Promise<void> = Promise.resolve();
/**
* End the identity provider's own login session at sign-out, best effort.
* Revoking the refresh token ends the app's grant, but the session the
* provider keeps in the browser stays, and the next browser sign-in would go
* through without asking (webmail #905). Only a browser sign-in left one
* (source 'native'); the password and TOTP sign-ins and the webmail's
* hand-off and pairing did not, or own it. Opens the logout page in the
* auth browser session, one at a time. Never throws and is not awaited by
* the sign-out.
*/
export function endProviderSession(serverUrl: string, tokens: OAuthTokens): Promise<void> {
if (tokens.source !== 'native') return Promise.resolve();
const run = async () => {
try {
const metadata = await discoverOAuthMetadata(serverUrl);
const target = metadata ? buildEndSessionUrl(serverUrl, metadata, tokens) : null;
if (!target) return;
await WebBrowser.openAuthSessionAsync(target.url, target.returnUrl);
} catch {
// never block or fail a sign-out
}
};
endSessionQueue = endSessionQueue.then(run, run);
return endSessionQueue;
}
/**
* Best-effort RFC 7009 revocation of a refresh token on sign-out. Never
* throws; the caller is already dropping the credentials.
+5
View File
@@ -26,6 +26,9 @@ export interface OAuthTokens {
// the desktop's token, so sign-out leaves `pairing` bundles alone instead
// of revoking them at the mail server; every other source is revoked.
source?: OAuthTokenSource;
// The OIDC id token of the sign-in, kept for ending the provider's
// session at sign-out (id_token_hint). A refresh may hand out a new one.
idToken?: string;
}
export type HandoffResult =
@@ -681,6 +684,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
try {
data = (await response.json()) as typeof data;
@@ -702,6 +706,7 @@ export async function refreshOAuthAccessToken(tokens: OAuthTokens): Promise<OAut
tokenEndpoint: tokens.tokenEndpoint,
clientId: tokens.clientId,
source: tokens.source,
idToken: typeof data.id_token === 'string' && data.id_token ? data.id_token : tokens.idToken,
};
rememberRotation(cacheKey, next);
return next;
+10
View File
@@ -49,6 +49,13 @@ export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
*/
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
/**
* Where the identity provider sends the browser back after sign-out ended
* its session (OIDC RP-Initiated Logout), registered with the client the app
* registers. Same scheme as the sign-in redirect, which the app handles.
*/
export const REGISTERED_LOGOUT_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/logout';
/**
* The app's own client, which the webmail's admin OAuth setup registers on
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
@@ -174,6 +181,7 @@ async function registerPublicClient(
body: JSON.stringify({
client_name: 'Bulwark Mobile',
redirect_uris: [REGISTERED_REDIRECT_URI],
post_logout_redirect_uris: [REGISTERED_LOGOUT_REDIRECT_URI],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
@@ -552,6 +560,7 @@ export async function exchangePasswordForTokens(
access_token?: string;
expires_in?: number;
refresh_token?: string;
id_token?: string;
};
if (!tokens.access_token) {
throw new TotpLoginError('token_exchange_failed', 'Token response missing access_token');
@@ -564,5 +573,6 @@ export async function exchangePasswordForTokens(
tokenEndpoint: tokenUrl,
clientId: client.clientId,
source: 'totp',
...(typeof tokens.id_token === 'string' && tokens.id_token ? { idToken: tokens.id_token } : {}),
};
}
+5 -2
View File
@@ -33,7 +33,7 @@ import {
forgetRefreshRotations,
type HandoffResult,
} from '../lib/oauth';
import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
import { discoverOAuthMetadata, endProviderSession, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native';
import {
teardownPushNotifications,
teardownPushNotificationsForAccount,
@@ -501,7 +501,9 @@ function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: Si
// desktop's own refresh token, and newer ones hand out a separate grant whose
// refresh token only the webmail's token proxy understands. The account's
// chain also leaves the in-process rotation memory either way, so a stale copy
// refreshed after the sign-out can't pick up its tokens from there.
// refreshed after the sign-out can't pick up its tokens from there. A browser
// sign-in's provider session is ended too, in the background: the sign-out
// never waits for the logout page (see endProviderSession).
async function revokeStoredRefreshToken(accountId: string): Promise<void> {
try {
const entry = useAccountStore.getState().getAccountById(accountId);
@@ -509,6 +511,7 @@ async function revokeStoredRefreshToken(accountId: string): Promise<void> {
forgetRefreshRotations(tokens?.refreshToken);
if (!entry || !tokens || tokens.source === 'pairing') return;
await revokeRefreshToken(entry.serverUrl, tokens);
void endProviderSession(entry.serverUrl, tokens);
} catch {
// never block sign-out
}