mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: redeem a one-time code from the webmail instead of reading the sign-in from the link
This commit is contained in:
@@ -799,6 +799,15 @@
|
|||||||
"err_code_used_title": "That code was already used",
|
"err_code_used_title": "That code was already used",
|
||||||
"err_generic_detail": "Something went wrong. Try again.",
|
"err_generic_detail": "Something went wrong. Try again.",
|
||||||
"err_generic_title": "Sign-in failed",
|
"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_detail": "The response didn't match the request we started. Try signing in again.",
|
||||||
"err_interrupted_title": "Sign-in was interrupted",
|
"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.",
|
"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
@@ -1,13 +1,16 @@
|
|||||||
import * as WebBrowser from 'expo-web-browser';
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
import { fetchWithoutForeignRedirect, secureFetch } from './client-cert';
|
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
|
// Webmail-mediated login. The app opens the webmail's normal login page with
|
||||||
// extra `mobile_redirect_uri` and `mobile_state` query params. The webmail
|
// extra `mobile_redirect_uri`, `mobile_state` and `mobile_code_challenge`
|
||||||
// uses its existing password and OAuth flows, and once the user is signed
|
// query params. The webmail uses its existing password and OAuth flows, and
|
||||||
// in, redirects back to the app's custom scheme with credentials packed into
|
// once the user is signed in, redirects back to the app's custom scheme with
|
||||||
// the URL fragment. Fragments aren't sent to the server, so password and
|
// a one-time code in the query string; the app redeems it with the PKCE
|
||||||
// token material don't appear in HTTP access logs along the way.
|
// 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';
|
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 base = webmailUrl.replace(/\/+$/, '');
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
|
mobile_redirect_uri: HANDOFF_REDIRECT_URI,
|
||||||
mobile_state: state,
|
mobile_state: state,
|
||||||
});
|
});
|
||||||
|
if (codeChallenge) params.set('mobile_code_challenge', codeChallenge);
|
||||||
return `${base}/login?${params.toString()}`;
|
return `${base}/login?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,12 +226,168 @@ function parseFragment(url: string): URLSearchParams {
|
|||||||
return new URLSearchParams(url.slice(hashIdx + 1));
|
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(
|
export async function runWebmailHandoff(
|
||||||
webmailUrl: string,
|
webmailUrl: string,
|
||||||
opts?: { addAccount?: boolean },
|
opts?: { addAccount?: boolean },
|
||||||
): Promise<HandoffResult> {
|
): Promise<HandoffResult> {
|
||||||
const state = randomState();
|
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
|
// 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
|
// 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}`);
|
throw new HandoffError(`Sign-in failed: ${result.type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = parseFragment(result.url);
|
const fragment = parseFragment(result.url);
|
||||||
const err = params.get('error');
|
const query = parseQuery(result.url);
|
||||||
|
const err = fragment.get('error') ?? query.get('error');
|
||||||
if (err) throw new HandoffError(err);
|
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
|
// 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
|
// unchanged. A mismatch means the redirect didn't come from the flow we
|
||||||
// started, and the rest of the fragment shouldn't be trusted.
|
// 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');
|
throw new HandoffError('State mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
const flow = params.get('flow');
|
return handoffResultFrom((key) => fragment.get(key) || undefined, webmailUrl);
|
||||||
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'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR login payloads. A QR scanned on the login screen either bootstraps the
|
// QR login payloads. A QR scanned on the login screen either bootstraps the
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
normalizeServerUrl,
|
normalizeServerUrl,
|
||||||
} from '../lib/server-discovery';
|
} from '../lib/server-discovery';
|
||||||
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
|
import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors';
|
||||||
|
import { describeHandoffRedeemError } from './login/handoff-errors';
|
||||||
import {
|
import {
|
||||||
clearClientCertAlias,
|
clearClientCertAlias,
|
||||||
getClientCertAlias,
|
getClientCertAlias,
|
||||||
@@ -346,7 +347,8 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
if (!isLive(attempt) || isSignInCancelled(err)) return;
|
||||||
if (continueWithSecondFactor(err, target)) 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 {
|
} finally {
|
||||||
if (endAttempt(attempt)) setBusy(null);
|
if (endAttempt(attempt)) setBusy(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user