feat: redeem a one-time code from the webmail instead of reading the sign-in from the link

This commit is contained in:
Linus Rath
2026-10-31 14:53:57 +01:00
parent 7b249ab9d9
commit ab2e5a60f8
5 changed files with 425 additions and 55 deletions
+9
View File
@@ -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.",
@@ -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);
});
});
+207 -54
View File
@@ -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<Record<string, unknown>> {
const base = webmailUrl.replace(/\/+$/, '');
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<never>((_, 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<string, unknown> | 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<HandoffResult> {
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<HandoffResult> {
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
+3 -1
View File
@@ -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);
}
+43
View File
@@ -0,0 +1,43 @@
import type { LoginErrorCopy } from '../../lib/login-errors';
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => 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.'),
};
}
}