mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +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_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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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