diff --git a/src/api/__tests__/live-stalwart-end-session.test.ts b/src/api/__tests__/live-stalwart-end-session.test.ts new file mode 100644 index 00000000..9b322a7e --- /dev/null +++ b/src/api/__tests__/live-stalwart-end-session.test.ts @@ -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); + } + }); +}); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 880f93d9..0a8d2ebb 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -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 } : {}), }; } diff --git a/src/lib/__tests__/oauth-end-session.test.ts b/src/lib/__tests__/oauth-end-session.test.ts new file mode 100644 index 00000000..3644131b --- /dev/null +++ b/src/lib/__tests__/oauth-end-session.test.ts @@ -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; +const browser = WebBrowser.openAuthSessionAsync as ReturnType; + +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 { + 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'); + }); +}); diff --git a/src/lib/oauth-native.ts b/src/lib/oauth-native.ts index 9b8ed8dd..3a0a2e3b 100644 --- a/src/lib/oauth-native.ts +++ b/src/lib/oauth-native.ts @@ -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, + tokens: Pick, +): { 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 = 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 { + 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. diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index bb2b772f..fb302726 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -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) => 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 { try { const entry = useAccountStore.getState().getAccountById(accountId); @@ -509,6 +511,7 @@ async function revokeStoredRefreshToken(accountId: string): Promise { 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 }