diff --git a/locales/rn/en.json b/locales/rn/en.json index 14c685e0..cf04038b 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -799,6 +799,15 @@ "err_code_used_title": "That code was already used", "err_generic_detail": "Something went wrong. Try again.", "err_generic_title": "Sign-in failed", + "err_handoff_expired_detail": "The browser handed the sign-in back after it had expired. Sign in again.", + "err_handoff_expired_title": "The sign-in took too long", + "err_handoff_network_detail": "The webmail could not be reached after you signed in. Check your connection and sign in again.", + "err_handoff_network_title": "Couldn't finish signing in", + "err_handoff_rate_detail": "Wait a few minutes, then sign in again.", + "err_handoff_refused_detail": "The sign-in link was already used or did not come from this sign-in. Sign in again.", + "err_handoff_refused_title": "The webmail didn't accept this sign-in", + "err_handoff_server_detail": "Sign in again. If it keeps failing, ask your administrator.", + "err_handoff_server_title": "The webmail couldn't complete the sign-in", "err_interrupted_detail": "The response didn't match the request we started. Try signing in again.", "err_interrupted_title": "Sign-in was interrupted", "err_no_server_detail": "Double-check the address, or scan a sign-in code from the webmail instead.", diff --git a/src/lib/__tests__/oauth-handoff-code.test.ts b/src/lib/__tests__/oauth-handoff-code.test.ts new file mode 100644 index 00000000..314820eb --- /dev/null +++ b/src/lib/__tests__/oauth-handoff-code.test.ts @@ -0,0 +1,163 @@ +import { createHash } from 'crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as WebBrowser from 'expo-web-browser'; + +const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() })); +vi.mock('../client-cert', () => ({ secureFetch: mockFetch, fetchWithoutForeignRedirect: mockFetch })); + +import { createHandoffPkce, HandoffRedeemError, runWebmailHandoff } from '../oauth'; +import { describeHandoffRedeemError } from '../../screens/login/handoff-errors'; + +// The browser hand-off with a one-time code (webmail fb3ba0275, +// bulwarkmail.org/docs/development/mobile-handoff): the app sends a PKCE +// challenge, the webmail answers with ?code=&state=, and the app redeems the +// code with its verifier. Older webmails still answer in the fragment. + +const WEBMAIL = 'https://webmail.example.com'; +const SERVER = 'https://webmail.example.com'; +const CODE = 'a'.repeat(64); + +let opened: URL; +let callback: (url: URL) => string; +let redeem: (body: { code: string; code_verifier: string }) => { status: number; data: unknown }; + +function jsonResponse(url: string, status: number, data: unknown): Response { + return { ok: status >= 200 && status < 300, status, url, redirected: false, json: async () => data } as Response; +} + +const s256 = (verifier: string) => createHash('sha256').update(verifier).digest('base64url'); + +beforeEach(() => { + vi.resetAllMocks(); + callback = (url) => `bulwarkmobile://auth/callback?${new URLSearchParams({ code: CODE, state: url.searchParams.get('mobile_state')! })}`; + redeem = () => ({ + status: 200, + data: { flow: 'password', server_url: SERVER, username: 'alice@example.org', password: 'pw', state: opened.searchParams.get('mobile_state') }, + }); + vi.mocked(WebBrowser.openAuthSessionAsync).mockImplementation(async (url) => { + opened = new URL(url); + return { type: 'success', url: callback(opened) }; + }); + mockFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === `${WEBMAIL}/api/auth/handoff/redeem`) { + const { status, data } = redeem(JSON.parse(String(init?.body))); + return jsonResponse(url, status, data); + } + throw new Error(`Unexpected fetch: ${url}`); + }); +}); + +describe('createHandoffPkce', () => { + it('makes a fresh 43-character verifier and its S256 challenge', () => { + const a = createHandoffPkce(); + const b = createHandoffPkce(); + expect(a.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(a.challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(a.challenge).toBe(s256(a.verifier)); + expect(b.verifier).not.toBe(a.verifier); + }); +}); + +describe('runWebmailHandoff with a one-time code', () => { + it('sends a challenge and redeems the code with the matching verifier', async () => { + let sent: { code: string; code_verifier: string } | null = null; + const next = redeem; + redeem = (body) => { sent = body; return next(body); }; + + await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({ + flow: 'password', serverUrl: SERVER, username: 'alice@example.org', password: 'pw', + }); + + const challenge = opened.searchParams.get('mobile_code_challenge'); + expect(challenge).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(opened.searchParams.get('mobile_redirect_uri')).toBe('bulwarkmobile://auth/callback'); + expect(sent!.code).toBe(CODE); + expect(s256(sent!.code_verifier)).toBe(challenge); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.method).toBe('POST'); + const headerNames = Object.keys(init.headers).map((h) => h.toLowerCase()); + expect(headerNames).not.toContain('origin'); + expect(headerNames).not.toContain('sec-fetch-site'); + }); + + it('uses a new challenge for every sign-in', async () => { + await runWebmailHandoff(WEBMAIL); + const first = opened.searchParams.get('mobile_code_challenge'); + await runWebmailHandoff(WEBMAIL); + expect(opened.searchParams.get('mobile_code_challenge')).not.toBe(first); + }); + + it('reads an OAuth bundle whose expires_in is a number', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_000_000); + redeem = () => ({ + status: 200, + data: { + flow: 'oauth', server_url: SERVER, access_token: 'at', refresh_token: 'rt', expires_in: 3540, + token_endpoint: `${WEBMAIL}/api/auth/pair/token`, client_id: 'bulwark-webmail', + state: opened.searchParams.get('mobile_state'), + }, + }); + await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({ + flow: 'oauth', + serverUrl: SERVER, + tokens: { + accessToken: 'at', refreshToken: 'rt', expiresAt: 1_000_000 + 3540 * 1000, + tokenEndpoint: `${WEBMAIL}/api/auth/pair/token`, clientId: 'bulwark-webmail', source: 'handoff', + }, + }); + vi.mocked(Date.now).mockRestore(); + }); + + it('refuses a link whose state does not match, without spending the code', async () => { + callback = () => `bulwarkmobile://auth/callback?${new URLSearchParams({ code: CODE, state: 'forged' })}`; + await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('refuses a redeem answer that names another state', async () => { + redeem = () => ({ status: 200, data: { flow: 'password', server_url: SERVER, username: 'a', password: 'b', state: 'other' } }); + await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch'); + }); + + for (const error of ['expired_code', 'invalid_code', 'invalid_verifier', 'invalid_request'] as const) { + it(`turns a 400 ${error} into a redeem error that asks to sign in again`, async () => { + redeem = () => ({ status: 400, data: { error } }); + const err = await runWebmailHandoff(WEBMAIL).catch((e: unknown) => e); + expect(err).toBeInstanceOf(HandoffRedeemError); + expect(err).toMatchObject({ reason: error, status: 400 }); + const copy = describeHandoffRedeemError(err, (_k, fallback) => fallback ?? _k); + expect(copy?.detail).toMatch(/sign in again/i); + }); + } + + it('treats an unknown 400 as a used code', async () => { + redeem = () => ({ status: 400, data: null }); + await expect(runWebmailHandoff(WEBMAIL)).rejects.toMatchObject({ name: 'HandoffRedeemError', reason: 'invalid_code' }); + }); + + it('keeps reading the fragment of an older webmail or Lite', async () => { + callback = (url) => `bulwarkmobile://auth/callback#${new URLSearchParams({ + flow: 'password', server_url: SERVER, username: 'bob@example.org', password: 'pw2', state: url.searchParams.get('mobile_state')!, + })}`; + await expect(runWebmailHandoff(WEBMAIL)).resolves.toEqual({ + flow: 'password', serverUrl: SERVER, username: 'bob@example.org', password: 'pw2', + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('refuses a fragment with the wrong state', async () => { + callback = () => `bulwarkmobile://auth/callback#${new URLSearchParams({ + flow: 'password', server_url: SERVER, username: 'bob@example.org', password: 'pw2', state: 'forged', + })}`; + await expect(runWebmailHandoff(WEBMAIL)).rejects.toThrow('State mismatch'); + }); + + it('sends no challenge to a plain-http webmail, which answers in the fragment', async () => { + callback = (url) => `bulwarkmobile://auth/callback#${new URLSearchParams({ + flow: 'password', server_url: 'https://mail.example.org', username: 'c', password: 'd', state: url.searchParams.get('mobile_state')!, + })}`; + await runWebmailHandoff('http://webmail.example.com'); + expect(opened.searchParams.has('mobile_code_challenge')).toBe(false); + }); +}); diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts index fb302726..e6533305 100644 --- a/src/lib/oauth.ts +++ b/src/lib/oauth.ts @@ -1,13 +1,16 @@ import * as WebBrowser from 'expo-web-browser'; import { fetchWithoutForeignRedirect, secureFetch } from './client-cert'; -import { randomHex } from './random'; +import { base64UrlEncode, randomBytes, randomHex } from './random'; +import { sha256Hex } from './sha256'; // Webmail-mediated login. The app opens the webmail's normal login page with -// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail -// uses its existing password and OAuth flows, and once the user is signed -// in, redirects back to the app's custom scheme with credentials packed into -// the URL fragment. Fragments aren't sent to the server, so password and -// token material don't appear in HTTP access logs along the way. +// extra `mobile_redirect_uri`, `mobile_state` and `mobile_code_challenge` +// query params. The webmail uses its existing password and OAuth flows, and +// once the user is signed in, redirects back to the app's custom scheme with +// a one-time code in the query string; the app redeems it with the PKCE +// verifier for the sign-in (another app that catches the link can't). Older +// webmails and Lite ignore the challenge and put the credentials in the URL +// fragment, which is still read. export const HANDOFF_REDIRECT_URI = 'bulwarkmobile://auth/callback'; @@ -190,12 +193,30 @@ async function isTrustedHandoffEndpoint( } } -function buildHandoffUrl(webmailUrl: string, state: string): string { +/** + * PKCE pair for the hand-off (bulwarkmail.org/docs/development/mobile-handoff): + * a fresh 43-character verifier from the CSPRNG for every sign-in, and its + * S256 challenge. + */ +export function createHandoffPkce(): { verifier: string; challenge: string } { + const verifier = base64UrlEncode(randomBytes(32)); + const challenge = base64UrlEncode(hexToBytes(sha256Hex(new TextEncoder().encode(verifier)))); + return { verifier, challenge }; +} + +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + return out; +} + +export function buildHandoffUrl(webmailUrl: string, state: string, codeChallenge?: string): string { const base = webmailUrl.replace(/\/+$/, ''); const params = new URLSearchParams({ mobile_redirect_uri: HANDOFF_REDIRECT_URI, mobile_state: state, }); + if (codeChallenge) params.set('mobile_code_challenge', codeChallenge); return `${base}/login?${params.toString()}`; } @@ -205,12 +226,168 @@ function parseFragment(url: string): URLSearchParams { return new URLSearchParams(url.slice(hashIdx + 1)); } +function parseQuery(url: string): URLSearchParams { + const q = url.indexOf('?'); + if (q === -1) return new URLSearchParams(); + const h = url.indexOf('#', q); + return new URLSearchParams(url.slice(q + 1, h === -1 ? undefined : h)); +} + +/** + * Why redeeming a hand-off code failed. Every failure spends the code, so the + * sign-in has to start over. + * invalid_request / invalid_code / expired_code / invalid_verifier: the + * webmail's 400 answers + * rate_limited: 429; forbidden: 403 (a same-origin refusal) + * network / server / bad_response: the request itself + */ +export type HandoffRedeemReason = + | 'invalid_request' + | 'invalid_code' + | 'expired_code' + | 'invalid_verifier' + | 'rate_limited' + | 'forbidden' + | 'network' + | 'server' + | 'bad_response'; + +export class HandoffRedeemError extends HandoffError { + readonly reason: HandoffRedeemReason; + readonly status?: number; + + constructor(reason: HandoffRedeemReason, message: string, status?: number) { + super(message); + this.name = 'HandoffRedeemError'; + this.reason = reason; + this.status = status; + } +} + +export const HANDOFF_REDEEM_TIMEOUT_MS = 15_000; +const HANDOFF_400_REASONS: readonly HandoffRedeemReason[] = ['invalid_request', 'invalid_code', 'expired_code', 'invalid_verifier']; + +/** + * Trade the one-time code from the callback link for the parked sign-in. + * Sent like every native request, without Origin or Sec-Fetch-Site, which + * the route would refuse as cross-origin. The code is a credential, so a + * redirect to another host is not followed. + */ +export async function redeemHandoffCode( + webmailUrl: string, + code: string, + codeVerifier: string, +): Promise> { + const base = webmailUrl.replace(/\/+$/, ''); + const controller = new AbortController(); + let timer: ReturnType | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new HandoffRedeemError('network', 'Timed out redeeming the sign-in code')); + }, HANDOFF_REDEEM_TIMEOUT_MS); + }); + let status: number; + let ok: boolean; + let data: Record | null; + try { + const exchange = (async () => { + let response: Response; + try { + response = await fetchWithoutForeignRedirect(`${base}/api/auth/handoff/redeem`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ code, code_verifier: codeVerifier }), + signal: controller.signal, + timeoutMs: HANDOFF_REDEEM_TIMEOUT_MS, + }); + } catch (err) { + throw new HandoffRedeemError( + 'network', + `Could not redeem the sign-in code: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return { status: response.status, ok: response.ok, data: await readJsonBody(response) }; + })(); + ({ status, ok, data } = await Promise.race([exchange, deadline])); + } finally { + clearTimeout(timer); + } + + if (!ok) { + const error = data ? stringField(data, 'error') : undefined; + if (status === 400) { + const reason = HANDOFF_400_REASONS.find((r) => r === error) ?? 'invalid_code'; + throw new HandoffRedeemError(reason, `The sign-in code was refused (${error ?? status}). Sign in again.`, status); + } + if (status === 429) throw new HandoffRedeemError('rate_limited', 'Too many sign-in attempts (429)', status); + if (status === 403) throw new HandoffRedeemError('forbidden', 'The webmail refused the sign-in code (403)', status); + throw new HandoffRedeemError('server', `Redeeming the sign-in code failed with status ${status}`, status); + } + if (!data) throw new HandoffRedeemError('bad_response', 'The sign-in response was not JSON', status); + return data; +} + +// Reads one field of the sign-in, from the fragment (strings) or the redeem +// answer (JSON, where expires_in is a number). +type BundleField = (key: string) => string | undefined; + +async function handoffResultFrom(field: BundleField, webmailUrl: string): Promise { + const flow = field('flow'); + const serverUrl = field('server_url') ?? null; + if (!serverUrl) throw new HandoffError('Sign-in response missing server URL'); + if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) { + throw new HandoffError('Sign-in response server URL must use https'); + } + + if (flow === 'password') { + const username = field('username'); + const password = field('password'); + if (!username || !password) { + throw new HandoffError('Sign-in response missing credentials'); + } + return { flow: 'password', serverUrl, username, password }; + } + + if (flow === 'oauth') { + const accessToken = field('access_token'); + const tokenEndpoint = field('token_endpoint'); + const clientId = field('client_id'); + if (!accessToken || !tokenEndpoint || !clientId) { + throw new HandoffError('Sign-in response missing OAuth tokens'); + } + if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) { + throw new HandoffError('Sign-in response token endpoint is not trusted'); + } + const refreshToken = field('refresh_token'); + const expiresIn = Number.parseInt(field('expires_in') ?? '', 10); + return { + flow: 'oauth', + serverUrl, + tokens: { + accessToken, + refreshToken, + expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1000 : undefined, + tokenEndpoint, + clientId, + source: 'handoff', + }, + }; + } + + throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`); +} + export async function runWebmailHandoff( webmailUrl: string, opts?: { addAccount?: boolean }, ): Promise { const state = randomState(); - const handoffUrl = buildHandoffUrl(webmailUrl, state); + // The redeem answer carries the sign-in, so it only goes over TLS (or + // loopback http in development). A plain-http webmail gets no challenge + // and answers in the fragment, as before. + const pkce = isHttpsUrl(webmailUrl) || isLoopbackHttp(webmailUrl) ? createHandoffPkce() : null; + const handoffUrl = buildHandoffUrl(webmailUrl, state, pkce?.challenge); // Adding an account: on iOS, keep the browser's webmail and identity // provider cookies out of the session so the signed-in account can't @@ -229,60 +406,36 @@ export async function runWebmailHandoff( throw new HandoffError(`Sign-in failed: ${result.type}`); } - const params = parseFragment(result.url); - const err = params.get('error'); + const fragment = parseFragment(result.url); + const query = parseQuery(result.url); + const err = fragment.get('error') ?? query.get('error'); if (err) throw new HandoffError(err); + // A webmail that got the challenge answers with a one-time code in the + // query string; older webmails and Lite put the sign-in in the fragment. + const code = query.get('code'); + if (pkce && code && !fragment.has('flow')) { + // CSRF guard: the state must round-trip unchanged before the code is + // spent; a link with another state didn't come from this sign-in. + if (query.get('state') !== state) throw new HandoffError('State mismatch'); + const bundle = await redeemHandoffCode(webmailUrl, code, pkce.verifier); + const echoed = stringField(bundle, 'state'); + if (echoed !== undefined && echoed !== state) throw new HandoffError('State mismatch'); + return handoffResultFrom((key) => { + const value = bundle[key]; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return typeof value === 'string' && value ? value : undefined; + }, webmailUrl); + } + // CSRF guard: the state we generated must round-trip through the webmail // unchanged. A mismatch means the redirect didn't come from the flow we // started, and the rest of the fragment shouldn't be trusted. - if (params.get('state') !== state) { + if (fragment.get('state') !== state) { throw new HandoffError('State mismatch'); } - const flow = params.get('flow'); - const serverUrl = params.get('server_url'); - if (!serverUrl) throw new HandoffError('Sign-in response missing server URL'); - if (!isHttpsUrl(serverUrl) && !isLoopbackHttp(serverUrl)) { - throw new HandoffError('Sign-in response server URL must use https'); - } - - if (flow === 'password') { - const username = params.get('username'); - const password = params.get('password'); - if (!username || !password) { - throw new HandoffError('Sign-in response missing credentials'); - } - return { flow: 'password', serverUrl, username, password }; - } - - if (flow === 'oauth') { - const accessToken = params.get('access_token'); - const tokenEndpoint = params.get('token_endpoint'); - const clientId = params.get('client_id'); - if (!accessToken || !tokenEndpoint || !clientId) { - throw new HandoffError('Sign-in response missing OAuth tokens'); - } - if (!(await isTrustedHandoffEndpoint(tokenEndpoint, serverUrl, webmailUrl, clientId))) { - throw new HandoffError('Sign-in response token endpoint is not trusted'); - } - const refreshToken = params.get('refresh_token') ?? undefined; - const expiresIn = params.get('expires_in'); - return { - flow: 'oauth', - serverUrl, - tokens: { - accessToken, - refreshToken, - expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : undefined, - tokenEndpoint, - clientId, - source: 'handoff', - }, - }; - } - - throw new HandoffError(`Unknown sign-in flow: ${flow ?? 'missing'}`); + return handoffResultFrom((key) => fragment.get(key) || undefined, webmailUrl); } // QR login payloads. A QR scanned on the login screen either bootstraps the diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 9ec0dadb..0ef171e7 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -20,6 +20,7 @@ import { normalizeServerUrl, } from '../lib/server-discovery'; import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors'; +import { describeHandoffRedeemError } from './login/handoff-errors'; import { clearClientCertAlias, getClientCertAlias, @@ -346,7 +347,8 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } catch (err) { if (!isLive(attempt) || isSignInCancelled(err)) return; if (continueWithSecondFactor(err, target)) return; - setNotice(describeLoginError(err, { serverUrl: target, t })); + // A refused sign-in code is spent: say so; Continue starts over. + setNotice(describeHandoffRedeemError(err, t) ?? describeLoginError(err, { serverUrl: target, t })); } finally { if (endAttempt(attempt)) setBusy(null); } diff --git a/src/screens/login/handoff-errors.ts b/src/screens/login/handoff-errors.ts new file mode 100644 index 00000000..c25677d0 --- /dev/null +++ b/src/screens/login/handoff-errors.ts @@ -0,0 +1,43 @@ +import type { LoginErrorCopy } from '../../lib/login-errors'; + +type Translate = (key: string, fallback?: string, params?: Record) => string; + +/** + * What to show when the webmail refused the one-time code of a browser + * sign-in (HandoffRedeemError in lib/oauth). Every refusal spends the code, + * so each message ends with signing in again, which the Continue button on + * the same step does. Null for any other error. + */ +export function describeHandoffRedeemError(err: unknown, t: Translate): LoginErrorCopy | null { + if (!(err instanceof Error) || err.name !== 'HandoffRedeemError') return null; + const reason = (err as { reason?: string }).reason; + switch (reason) { + case 'expired_code': + return { + title: t('login.mobile.err_handoff_expired_title', 'The sign-in took too long'), + detail: t('login.mobile.err_handoff_expired_detail', 'The browser handed the sign-in back after it had expired. Sign in again.'), + }; + case 'invalid_code': + case 'invalid_verifier': + case 'invalid_request': + return { + title: t('login.mobile.err_handoff_refused_title', "The webmail didn't accept this sign-in"), + detail: t('login.mobile.err_handoff_refused_detail', 'The sign-in link was already used or did not come from this sign-in. Sign in again.'), + }; + case 'rate_limited': + return { + title: t('login.mobile.err_rate_title', 'Too many attempts'), + detail: t('login.mobile.err_handoff_rate_detail', 'Wait a few minutes, then sign in again.'), + }; + case 'network': + return { + title: t('login.mobile.err_handoff_network_title', "Couldn't finish signing in"), + detail: t('login.mobile.err_handoff_network_detail', 'The webmail could not be reached after you signed in. Check your connection and sign in again.'), + }; + default: + return { + title: t('login.mobile.err_handoff_server_title', "The webmail couldn't complete the sign-in"), + detail: t('login.mobile.err_handoff_server_detail', 'Sign in again. If it keeps failing, ask your administrator.'), + }; + } +}