From 4bebf7048271de1a2b5437448facac095a07b955 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:05:16 +0200 Subject: [PATCH] feat: sign in with OAuth (PKCE) directly against the mail server and revoke refresh tokens on sign-out --- src/api/jmap-client.ts | 18 ++ src/lib/login-errors.ts | 7 + src/lib/oauth-native.ts | 216 ++++++++++++++++++++++++ src/lib/oauth.ts | 9 + src/lib/totp-login.ts | 1 + src/stores/__tests__/auth-store.test.ts | 1 + src/stores/auth-store.ts | 66 +++++++- 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/lib/oauth-native.ts diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 76dafdac..4fdfbfaf 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -12,6 +12,7 @@ import { refreshOAuthAccessToken, TransientRefreshError, type OAuthTokens, + type OAuthTokenSource, } from '../lib/oauth'; import { FirstTouchGate } from './first-touch-gate'; @@ -54,6 +55,7 @@ export interface StoredCredentials { expiresAt?: number; tokenEndpoint?: string; clientId?: string; + tokenSource?: OAuthTokenSource; } /** @@ -301,6 +303,7 @@ export class JMAPClient { expiresAt: tokens.expiresAt, tokenEndpoint: tokens.tokenEndpoint, clientId: tokens.clientId, + tokenSource: tokens.source, }; this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl); @@ -364,6 +367,21 @@ export class JMAPClient { expiresAt: this.credentials.expiresAt, tokenEndpoint: this.credentials.tokenEndpoint, clientId: this.credentials.clientId, + source: this.credentials.tokenSource, + }; + } + + /** OAuth bundle stored for `accountId` (null for password accounts). */ + async getStoredOAuthTokens(accountId: string): Promise { + const creds = await this.getStoredCredentials(accountId); + if (!creds?.accessToken || !creds.refreshToken || !creds.tokenEndpoint || !creds.clientId) return null; + return { + accessToken: creds.accessToken, + refreshToken: creds.refreshToken, + expiresAt: creds.expiresAt, + tokenEndpoint: creds.tokenEndpoint, + clientId: creds.clientId, + source: creds.tokenSource, }; } diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index 595da995..b135546b 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -115,6 +115,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} }; } + if (lower.includes('no bulwark webmail or sign-in service')) { + return { + title: `No sign-in page at ${host}`, + detail: 'This server has no Bulwark webmail and no OAuth sign-in. Use a password (or an app password) instead.', + }; + } + if (lower.includes('pairing code')) { return { title: 'That code has expired', diff --git a/src/lib/oauth-native.ts b/src/lib/oauth-native.ts new file mode 100644 index 00000000..707b5bd4 --- /dev/null +++ b/src/lib/oauth-native.ts @@ -0,0 +1,216 @@ +// Direct OAuth 2.0 / OIDC authorization-code + PKCE login against the mail +// server's own authorization server (Stalwart's built-in OAuth, Keycloak, +// Authentik…), for deployments where no Bulwark webmail lives at the JMAP +// origin and the browser hand-off therefore has nothing to open. +// +// Mirrors the webmail's client-side PKCE flow (`lib/oauth/discovery.ts`, +// `lib/oauth/pkce.ts`, login page `handleOAuthLogin`): discover the metadata +// document, open the authorization endpoint in the system browser with a +// S256 challenge, receive the code on `bulwarkmobile://auth/callback`, and +// exchange it at the token endpoint. The resulting bundle is the same shape +// the hand-off produces, so `connectWithOAuth` and the refresh path apply. + +import * as WebBrowser from 'expo-web-browser'; +import { secureFetch } from './client-cert'; +import { HANDOFF_REDIRECT_URI, HandoffError, HandoffCancelledError, type OAuthTokens } from './oauth'; +import { generateCodeChallenge, generateCodeVerifier, DEFAULT_CLIENT_ID } from './totp-login'; +import { randomHex } from './random'; + +export interface OAuthMetadata { + issuer?: string; + authorization_endpoint: string; + token_endpoint: string; + revocation_endpoint?: string; + scopes_supported?: string[]; +} + +const DISCOVERY_TIMEOUT_MS = 8_000; + +function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } { + if (typeof AbortController === 'undefined') return { done: () => undefined }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + return { signal: controller.signal, done: () => clearTimeout(timer) }; +} + +function sameHost(a: string, b: string): boolean { + const host = (u: string) => (/^[a-z][a-z0-9+.-]*:\/\/([^/?#]+)/i.exec(u)?.[1] ?? '').toLowerCase().replace(/:\d+$/, ''); + const ha = host(a); + const hb = host(b); + return !!ha && !!hb && (ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`)); +} + +/** + * RFC 8414 / OIDC discovery on the mail server origin. Returns null when the + * server advertises no authorization server (plain Stalwart without OAuth, + * or a non-Stalwart JMAP server). + */ +export async function discoverOAuthMetadata(serverUrl: string): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const urls = [ + `${base}/.well-known/oauth-authorization-server`, + `${base}/.well-known/openid-configuration`, + ]; + for (const url of urls) { + const t = withTimeout(DISCOVERY_TIMEOUT_MS); + try { + const res = await secureFetch(url, { headers: { Accept: 'application/json' }, signal: t.signal }); + if (!res.ok) continue; + const data = (await res.json()) as Partial; + if ( + typeof data.authorization_endpoint === 'string' && + typeof data.token_endpoint === 'string' && + /^https?:\/\//i.test(data.authorization_endpoint) && + /^https?:\/\//i.test(data.token_endpoint) + ) { + return data as OAuthMetadata; + } + } catch { + // try the next document + } finally { + t.done(); + } + } + return null; +} + +/** + * Is a Bulwark webmail serving the given origin? The hand-off opens + * `${origin}/login?mobile_redirect_uri=…`, which only works when it is. + */ +export async function probeWebmail(serverUrl: string): Promise { + const base = serverUrl.replace(/\/+$/, ''); + const t = withTimeout(DISCOVERY_TIMEOUT_MS); + try { + const res = await secureFetch(`${base}/api/config`, { headers: { Accept: 'application/json' }, signal: t.signal }); + if (!res.ok) return false; + const type = res.headers.get('content-type') ?? ''; + if (!/json/i.test(type)) return false; + const data = (await res.json().catch(() => null)) as Record | null; + return !!data && typeof data === 'object'; + } catch { + return false; + } finally { + t.done(); + } +} + +function parseCallbackParams(url: string): URLSearchParams { + const q = url.indexOf('?'); + const h = url.indexOf('#'); + const query = q >= 0 ? url.slice(q + 1, h > q ? h : undefined) : ''; + const params = new URLSearchParams(query); + if (h >= 0) { + for (const [k, v] of new URLSearchParams(url.slice(h + 1))) { + if (!params.has(k)) params.set(k, v); + } + } + return params; +} + +/** + * Run the authorization-code + PKCE flow in the system browser. Throws + * `HandoffCancelledError` when the user closes the browser. + */ +export async function loginWithPkce( + serverUrl: string, + metadata: OAuthMetadata, + opts?: { clientId?: string; scopes?: string; redirectUri?: string }, +): Promise { + const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID; + const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI; + const verifier = generateCodeVerifier(); + const challenge = generateCodeChallenge(verifier); + const state = randomHex(16); + const scope = opts?.scopes + ?? (metadata.scopes_supported?.length + ? ['openid', 'email', 'profile', 'offline_access'].filter((s) => metadata.scopes_supported!.includes(s)).join(' ') || metadata.scopes_supported.join(' ') + : 'openid email profile offline_access'); + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope, + state, + code_challenge: challenge, + code_challenge_method: 'S256', + }); + const authUrl = `${metadata.authorization_endpoint}${metadata.authorization_endpoint.includes('?') ? '&' : '?'}${params.toString()}`; + + const result = await WebBrowser.openAuthSessionAsync(authUrl, redirectUri); + if (result.type === 'cancel' || result.type === 'dismiss') throw new HandoffCancelledError(); + if (result.type !== 'success' || !result.url) throw new HandoffError(`Sign-in failed: ${result.type}`); + + const cb = parseCallbackParams(result.url); + const err = cb.get('error'); + if (err) throw new HandoffError(cb.get('error_description') || err); + if (cb.get('state') !== state) throw new HandoffError('State mismatch'); + const code = cb.get('code'); + if (!code) throw new HandoffError('Sign-in response missing authorization code'); + + if (!sameHost(metadata.token_endpoint, serverUrl) && !/^https:\/\//i.test(metadata.token_endpoint)) { + throw new HandoffError('Token endpoint is not trusted'); + } + + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: verifier, + }); + let response: Response; + try { + response = await secureFetch(metadata.token_endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, + body: body.toString(), + }); + } catch (e) { + throw new HandoffError(`Could not reach the token endpoint: ${e instanceof Error ? e.message : String(e)}`); + } + if (!response.ok) { + const detail = (await response.text().catch(() => '')).slice(0, 200); + throw new HandoffError(`Token exchange failed: ${response.status}${detail ? ` ${detail}` : ''}`); + } + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!data.access_token) throw new HandoffError('Token response missing access_token'); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_in ? Date.now() + data.expires_in * 1000 : undefined, + tokenEndpoint: metadata.token_endpoint, + clientId, + source: 'native', + }; +} + +/** + * Best-effort RFC 7009 revocation of a refresh token on sign-out. Never + * throws; the caller is already dropping the credentials. + */ +export async function revokeRefreshToken(serverUrl: string, tokens: OAuthTokens): Promise { + if (!tokens.refreshToken) return; + try { + const metadata = await discoverOAuthMetadata(serverUrl); + const endpoint = metadata?.revocation_endpoint; + if (!endpoint || !/^https?:\/\//i.test(endpoint)) return; + const body = new URLSearchParams({ + token: tokens.refreshToken, + token_type_hint: 'refresh_token', + client_id: tokens.clientId, + }); + await secureFetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + } catch { + // ignore + } +} diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index cfc73137..31ba28d2 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -11,12 +11,18 @@ import { randomHex } from './random'; export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback'; +export type OAuthTokenSource = 'handoff' | 'pairing' | 'totp' | 'native'; + export interface OAuthTokens { accessToken: string; refreshToken?: string; expiresAt?: number; // epoch ms tokenEndpoint: string; clientId: string; + // Where the bundle came from. A paired phone shares the desktop's refresh + // token, so revoking it on sign-out would also sign the desktop out; + // every other source is safe to revoke. + source?: OAuthTokenSource; } export type HandoffResult = @@ -162,6 +168,7 @@ export async function runWebmailHandoff(webmailUrl: string): Promise ({ onTokenRefresh: vi.fn(() => () => undefined), hasAccountCapability: vi.fn(() => false), request: vi.fn(async () => { throw new Error('not mocked'); }), + getStoredOAuthTokens: vi.fn(async () => null), accountId: 'acc-1', currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, username: 'user', diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index dbc7460f..d3ee7a0f 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -9,7 +9,8 @@ import { useContactsStore } from './contacts-store'; import { useCalendarStore } from './calendar-store'; import { useFilterStore } from './filter-store'; import { generateAccountId } from '../lib/account-utils'; -import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, type HandoffResult } from '../lib/oauth'; +import { runWebmailHandoff, redeemPairingCode, HandoffCancelledError, HandoffError, type HandoffResult } from '../lib/oauth'; +import { discoverOAuthMetadata, loginWithPkce, probeWebmail, revokeRefreshToken } from '../lib/oauth-native'; import { teardownPushNotifications, teardownPushNotificationsForAccount, @@ -48,6 +49,8 @@ export interface AuthState { opts?: { addAccount?: boolean; totp?: string }, ) => Promise; loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; + /** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */ + loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise; loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise; logout: () => Promise; logoutAll: () => Promise; @@ -107,6 +110,19 @@ function refetchFeatureStores(): void { } } +// Best-effort RFC 7009 revocation of an account's refresh token on sign-out. +// A QR-paired phone shares the desktop's token, so those are left alone. +async function revokeStoredRefreshToken(accountId: string): Promise { + try { + const entry = useAccountStore.getState().getAccountById(accountId); + const tokens = await jmapClient.getStoredOAuthTokens(accountId); + if (!entry || !tokens || tokens.source === 'pairing') return; + await revokeRefreshToken(entry.serverUrl, tokens); + } catch { + // never block sign-out + } +} + // Refresh the registry's display name / address from the server (#900): the // primary identity first, then the Stalwart principal's "Full name" when the // account advertises the extension. Fire-and-forget; a failure keeps whatever @@ -270,6 +286,20 @@ export const useAuthStore = create((set, get) => ({ loginViaWebmail: async (webmailUrl, opts) => { set({ isLoading: true, error: null }); + // Discovery finds the *JMAP* host; a Bulwark webmail is not necessarily + // served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart + // lands on a 404 or the admin page, so check first and fall back to the + // server's own OAuth (PKCE) when the webmail is missing. + if (!(await probeWebmail(webmailUrl))) { + const metadata = await discoverOAuthMetadata(webmailUrl); + if (metadata) { + await get().loginViaOAuth(webmailUrl, opts); + return; + } + const message = 'No Bulwark webmail or sign-in service found at this address. Use a password instead.'; + set({ isLoading: false, error: message }); + throw new HandoffError(message); + } let result; try { result = await runWebmailHandoff(webmailUrl); @@ -309,6 +339,37 @@ export const useAuthStore = create((set, get) => ({ } }, + loginViaOAuth: async (serverUrl, opts) => { + set({ isLoading: true, error: null }); + const base = serverUrl.replace(/\/+$/, ''); + let tokens; + try { + const metadata = await discoverOAuthMetadata(base); + if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in'); + tokens = await loginWithPkce(base, metadata); + } catch (err) { + if (err instanceof HandoffCancelledError) { + set({ isLoading: false, error: null }); + return; + } + const message = err instanceof Error ? err.message : 'Sign-in failed'; + set({ isLoading: false, error: message }); + throw err; + } + try { + await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts); + } catch (err) { + const message = + err instanceof AuthenticationError + ? 'Authentication rejected by server' + : err instanceof Error + ? err.message + : 'OAuth sign-in failed'; + set({ isLoading: false, error: message }); + throw err; + } + }, + loginViaPairing: async (webmailUrl, code, opts) => { set({ isLoading: true, error: null }); let result; @@ -356,6 +417,7 @@ export const useAuthStore = create((set, get) => ({ // Clear credentials for this account first if (currentId) { + await revokeStoredRefreshToken(currentId); await jmapClient.clearAccountCredentials(currentId); accountStore.removeAccount(currentId); } else { @@ -395,6 +457,7 @@ export const useAuthStore = create((set, get) => ({ const accountStore = useAccountStore.getState(); const ids = accountStore.accounts.map((a) => a.id); await teardownPushNotifications().catch(() => undefined); + for (const id of ids) await revokeStoredRefreshToken(id); await jmapClient.clearAllCredentials(ids); jmapClient.reset(); clearAllFeatureStores(); @@ -510,6 +573,7 @@ export const useAuthStore = create((set, get) => ({ const accountStore = useAccountStore.getState(); if (!accountStore.getAccountById(accountId)) return; await teardownPushNotificationsForAccount(accountId).catch(() => undefined); + await revokeStoredRefreshToken(accountId); await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); useEmailStore.getState().removeAccount(accountId); accountStore.removeAccount(accountId);