fix: let the user cancel a sign-in that hangs

This commit is contained in:
Linus Rath
2026-10-31 03:39:39 +01:00
parent 0e69c86c51
commit 55d008a2e8
13 changed files with 521 additions and 105 deletions
+5 -2
View File
@@ -279,11 +279,13 @@ export class JMAPClient {
username: string,
password: string,
totp?: string,
/** Cancels the token requests of a TOTP sign-in (the sign-in screen's Cancel). */
signal?: AbortSignal,
): Promise<JMAPSession> {
const baseUrl = serverUrl.replace(/\/+$/, '');
const epoch = this.detachSession();
if (totp) {
return this.connectWithTotp(baseUrl, username, password, totp, epoch);
return this.connectWithTotp(baseUrl, username, password, totp, epoch, signal);
}
const credentials: StoredCredentials = { serverUrl: baseUrl, username, password };
this.credentials = credentials;
@@ -314,11 +316,12 @@ export class JMAPClient {
password: string,
totp: string,
epoch: number,
signal?: AbortSignal,
): Promise<JMAPSession> {
const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login');
let tokens: OAuthTokens;
try {
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp);
tokens = await exchangePasswordForTokens(baseUrl, username, password, totp, { signal });
} catch (err) {
if (err instanceof LoginEndpointMissingError) {
this.assertCurrentSession(epoch);
@@ -196,4 +196,16 @@ describe('discoverServerForEmail', () => {
await expect(discoverServerForEmail('ada')).resolves.toBeNull();
expect(mockSecureFetch).not.toHaveBeenCalled();
});
it('stops searching as soon as the sign-in is cancelled (U-133)', async () => {
// Every host hangs; only the cancel can end the search before the deadline.
mockSecureFetch.mockImplementation(() => new Promise(() => undefined));
const controller = new AbortController();
const started = Date.now();
const search = discoverServerForEmail('ada@example.com', { timeoutMs: 60_000, signal: controller.signal });
setTimeout(() => controller.abort(), 20);
await expect(search).resolves.toBeNull();
expect(Date.now() - started).toBeLessThan(5_000);
expect(mockSecureFetch.mock.calls[0][1].signal.aborted).toBe(true);
});
});
+17
View File
@@ -101,6 +101,23 @@ describe('exchangePasswordForTokens', () => {
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
});
it('cancels the client check and the login requests with the sign-in screen (U-133)', async () => {
const signals: Array<AbortSignal | undefined> = [];
fetchMock.mockImplementation((_url: string, init?: RequestInit) => {
signals.push(init?.signal ?? undefined);
return new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new TypeError('Aborted')));
});
});
const controller = new AbortController();
const pending = exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456', { signal: controller.signal });
await vi.waitFor(() => expect(signals.length).toBe(1));
controller.abort();
// A cancel, not "server unreachable".
await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' });
expect(signals[0]?.aborted).toBe(true);
});
it('registers its own client on Stalwart 1.0 and signs in with it', async () => {
const calls = fakeServer('1.0');
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
+26 -18
View File
@@ -8,6 +8,7 @@
// slow or hostile network must never be worse than typing it in.
import { secureFetch } from './client-cert';
import { signalWithTimeout } from './sign-in-abort';
export const DISCOVERY_TIMEOUT_MS = 2500;
@@ -96,18 +97,21 @@ export function serverCandidates(domain: string): string[] {
return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`];
}
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve) => {
const timer = setTimeout(() => resolve(fallback), ms);
promise
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch(() => {
clearTimeout(timer);
resolve(fallback);
});
if (signal?.aborted) {
resolve(fallback);
return;
}
const finish = (value: T) => {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
resolve(value);
};
const onAbort = () => finish(fallback);
const timer = setTimeout(() => finish(fallback), ms);
signal?.addEventListener('abort', onAbort, { once: true });
promise.then(finish).catch(() => finish(fallback));
});
}
@@ -128,15 +132,16 @@ function looksLikeSession(body: unknown): boolean {
export async function probeJmapServer(
baseUrl: string,
timeoutMs: number = DISCOVERY_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<boolean> {
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
const abortTimer = setTimeout(() => controller?.abort(), timeoutMs);
// Gives up at the deadline, or when the user cancels the sign-in.
const deadline = signalWithTimeout(signal, timeoutMs);
const request = (async () => {
try {
const response = await secureFetch(`${baseUrl}/.well-known/jmap`, {
headers: { Accept: 'application/json' },
signal: controller?.signal,
signal: deadline.signal,
});
if (response.status === 401) return true;
if (response.status !== 200) return false;
@@ -149,9 +154,9 @@ export async function probeJmapServer(
try {
// The abort signal doesn't reach the native client-certificate path, so
// race the whole thing rather than trusting the request to give up.
return await withTimeout(request, timeoutMs, false);
return await withTimeout(request, timeoutMs, false, signal);
} finally {
clearTimeout(abortTimer);
deadline.done();
}
}
@@ -159,6 +164,8 @@ export interface DiscoveryOptions {
/** Servers of already-registered accounts, trusted without a probe. */
knownServerUrls?: string[];
timeoutMs?: number;
/** The sign-in screen's Cancel: discovery stops and finds nothing. */
signal?: AbortSignal;
}
/**
@@ -170,7 +177,7 @@ export async function discoverServerForEmail(
options: DiscoveryOptions = {},
): Promise<string | null> {
const domain = emailDomain(email);
if (!domain) return null;
if (!domain || options.signal?.aborted) return null;
// A server we're already signed in to for this domain needs no probe, and
// covers the common case of adding a second account on the same host.
@@ -186,8 +193,9 @@ export async function discoverServerForEmail(
const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS;
const candidates = serverCandidates(domain);
const results = await Promise.all(
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)),
candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs, options.signal)) ? candidate : null)),
);
if (options.signal?.aborted) return null;
// `find` keeps candidate order, so a bare domain wins over `mail.` even when
// both answer.
+73
View File
@@ -0,0 +1,73 @@
// Cancelling a sign-in (U-133). The sign-in screen used to show "Signing in"
// with no way out until every request gave up on its own, and Android Back
// was swallowed meanwhile. The screen now holds an AbortController per
// attempt; its signal goes into discovery, the client check, the /api/auth
// and token requests, and the auth store, which drops a sign-in whose signal
// fired before it registered the account.
export class SignInCancelledError extends Error {
constructor() {
super('Sign-in cancelled');
this.name = 'SignInCancelledError';
}
}
export function isSignInCancelled(err: unknown): boolean {
return err instanceof Error && err.name === 'SignInCancelledError';
}
export function throwIfCancelled(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw new SignInCancelledError();
}
/**
* `promise`, or a `SignInCancelledError` as soon as `signal` fires. The work
* behind the promise goes on; callers that need to undo what it does once it
* finishes keep their own reference to it.
*/
export function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) {
promise.catch(() => undefined);
return Promise.reject(new SignInCancelledError());
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new SignInCancelledError());
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(err) => {
signal.removeEventListener('abort', onAbort);
reject(signal.aborted ? new SignInCancelledError() : err);
},
);
});
}
/**
* One signal that fires when `signal` does or after `ms`, whichever comes
* first (AbortSignal.any and AbortSignal.timeout aren't in every engine the
* app runs on). Call `done` when the request has finished.
*/
export function signalWithTimeout(
signal: AbortSignal | undefined,
ms?: number,
): { signal?: AbortSignal; done: () => void } {
if (typeof AbortController === 'undefined') return { signal, done: () => undefined };
if (!signal && ms === undefined) return { done: () => undefined };
const controller = new AbortController();
const abort = () => controller.abort();
const timer = ms !== undefined ? setTimeout(abort, ms) : undefined;
if (signal?.aborted) controller.abort();
else signal?.addEventListener('abort', abort, { once: true });
return {
signal: controller.signal,
done: () => {
if (timer !== undefined) clearTimeout(timer);
signal?.removeEventListener('abort', abort);
},
};
}
+28 -19
View File
@@ -29,6 +29,7 @@ import { sha256Hex } from './sha256';
import { base64UrlEncode, randomBytes, randomHex } from './random';
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
import { NetworkError } from '../api/jmap-client';
import { signalWithTimeout, throwIfCancelled } from './sign-in-abort';
// Stalwart 0.16's built-in OAuth accepts any client id; the webmail's public
// one keeps refresh behaviour identical to the browser-handoff bundle.
@@ -134,13 +135,6 @@ function rememberClient(base: string, client: LoginClient): void {
loginClients.set(base, client);
}
function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } {
if (typeof AbortController === 'undefined') return { done: () => undefined };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
return { signal: controller.signal, done: () => clearTimeout(timer) };
}
// The password and the PKCE verifier must not follow a redirect to another
// host. The requests go out with fetchWithoutForeignRedirect, which hands such
// a redirect back unfollowed; where the platform followed it anyway, the
@@ -163,8 +157,12 @@ function assertSameServer(requested: string, response: Response): void {
* names, which may be on the public address it advertises rather than the one
* the user typed; the id is sealed with the server's key, so it serves both.
*/
async function registerPublicClient(base: string, url = `${base}/auth/register`): Promise<LoginClient> {
const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS);
async function registerPublicClient(
base: string,
url = `${base}/auth/register`,
signal?: AbortSignal,
): Promise<LoginClient> {
const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS);
let response: Response;
try {
response = await fetchWithoutForeignRedirect(url, {
@@ -182,6 +180,7 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`)
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
});
} catch (err) {
throwIfCancelled(signal);
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
} finally {
t.done();
@@ -207,6 +206,7 @@ async function checkClient(
base: string,
clientId: string,
redirectUri: string,
signal?: AbortSignal,
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
const query = new URLSearchParams({
response_type: 'code',
@@ -217,7 +217,7 @@ async function checkClient(
code_challenge_method: 'S256',
});
const url = `${base}/api/auth/client?${query.toString()}`;
const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS);
const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS);
let response: Response;
try {
response = await fetchWithoutForeignRedirect(url, {
@@ -226,6 +226,7 @@ async function checkClient(
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
});
} catch (err) {
throwIfCancelled(signal);
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
} finally {
t.done();
@@ -253,14 +254,15 @@ async function checkClient(
*/
export async function resolveLoginClient(
serverUrl: string,
opts?: { registrationEndpoint?: string },
opts?: { registrationEndpoint?: string; signal?: AbortSignal },
): Promise<LoginClient> {
const base = trimBase(serverUrl);
const cached = loginClients.get(base);
if (cached) return cached;
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
const signal = opts?.signal;
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI);
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, signal);
if (own === 'busy') return fallback;
if (own === 'known') {
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
@@ -268,11 +270,11 @@ export async function resolveLoginClient(
return client;
}
if (own === 'unknown') {
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI);
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, signal);
if (shared === 'busy') return fallback;
if (shared === 'unknown') {
const endpoint = opts?.registrationEndpoint;
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined, signal);
}
}
rememberClient(base, fallback);
@@ -386,6 +388,7 @@ async function requestLoginCode(
totp: string | undefined,
client: LoginClient,
challenge: string,
signal?: AbortSignal,
): Promise<StalwartLoginOutcome> {
const state = randomHex(16);
const url = `${base}/api/auth`;
@@ -403,8 +406,10 @@ async function requestLoginCode(
codeChallenge: challenge,
state,
})),
signal,
});
} catch (err) {
throwIfCancelled(signal);
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
}
assertSameServer(url, loginResponse);
@@ -434,26 +439,28 @@ export async function exchangePasswordForTokens(
username: string,
password: string,
totp?: string,
opts?: { clientId?: string; redirectUri?: string },
opts?: { clientId?: string; redirectUri?: string; signal?: AbortSignal },
): Promise<OAuthTokens> {
const base = trimBase(serverUrl);
const verifier = generateCodeVerifier();
const challenge = generateCodeChallenge(verifier);
const signal = opts?.signal;
throwIfCancelled(signal);
let client: LoginClient = opts?.clientId
? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI }
: await resolveLoginClient(base);
let outcome = await requestLoginCode(base, username, password, totp, client, challenge);
: await resolveLoginClient(base, { signal });
let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
// A remembered client the server no longer knows (another server now
// answers at the address, or its key changed), or the default taken while
// the client check failed: ask again, once. 1.0 checks the client before
// the credentials, so this costs no failed sign-in.
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) {
loginClients.delete(base);
const fresh = await resolveLoginClient(base);
const fresh = await resolveLoginClient(base, { signal });
if (fresh.clientId !== client.clientId) {
client = fresh;
outcome = await requestLoginCode(base, username, password, totp, client, challenge);
outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal);
}
}
@@ -486,8 +493,10 @@ export async function exchangePasswordForTokens(
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: tokenParams.toString(),
signal,
});
} catch (err) {
throwIfCancelled(signal);
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
}
assertSameServer(tokenUrl, tokenResponse);
+93 -33
View File
@@ -33,6 +33,8 @@ import ServerStep from './login/ServerStep';
import ConfirmStep from './login/ConfirmStep';
import PasswordStep from './login/PasswordStep';
import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
import { loginBackAction } from './login/login-back';
import { isSignInCancelled } from '../lib/sign-in-abort';
interface LoginScreenProps {
onLogin?: () => void;
@@ -117,6 +119,33 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// (in add mode the server fields still name the signed-in account's host).
const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState<string | null>(null);
// One controller per sign-in or server search (U-133). Cancel and Back
// abort it; a flow whose controller is no longer the current one drops
// whatever its awaits bring back, so a late answer neither signs in nor
// shows an error over the form the user went back to.
const attemptRef = React.useRef<AbortController | null>(null);
const beginAttempt = React.useCallback((): AbortController => {
attemptRef.current?.abort();
const controller = new AbortController();
attemptRef.current = controller;
return controller;
}, []);
const isLive = (attempt: AbortController) => attemptRef.current === attempt && !attempt.signal.aborted;
const endAttempt = React.useCallback((attempt: AbortController): boolean => {
if (attemptRef.current !== attempt) return false;
attemptRef.current = null;
return true;
}, []);
const cancelSignIn = React.useCallback(() => {
attemptRef.current?.abort();
attemptRef.current = null;
setBusy(null);
setSearching(false);
setPairingWebmailUrl(null);
setNotice(null);
}, []);
React.useEffect(() => () => attemptRef.current?.abort(), []);
// Addresses typed before on this device (last 5) plus the registry's, so a
// returning user taps instead of retyping. Never contains passwords.
const [recentEmails, setRecentEmails] = React.useState<string[]>([]);
@@ -216,27 +245,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// out of the flow entirely from the first step.
React.useEffect(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (busy || searching) return true;
if (scannerVisible) {
setScannerVisible(false);
return true;
const action = loginBackAction({
busy: !!busy,
searching,
scannerVisible,
pasteVisible,
historyLength: history.length,
canLeave: isAddMode && !!onCancel,
});
switch (action) {
case 'cancel':
cancelSignIn();
return true;
case 'close-scanner':
setScannerVisible(false);
return true;
case 'close-paste':
setPasteVisible(false);
return true;
case 'step-back':
goBack();
return true;
case 'leave':
onCancel?.();
return true;
default:
return false;
}
if (pasteVisible) {
setPasteVisible(false);
return true;
}
if (history.length > 0) {
goBack();
return true;
}
if (isAddMode && onCancel) {
onCancel();
return true;
}
return false;
});
return () => subscription.remove();
}, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]);
}, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel, cancelSignIn]);
// ── flows ──────────────────────────────────────────────
@@ -279,21 +317,24 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const runHandoff = React.useCallback(
async (target: string) => {
setNotice(null);
const attempt = beginAttempt();
setBusy('browser');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaWebmail(target, { addAccount: isAddMode });
await loginViaWebmail(target, { addAccount: isAddMode, signal: attempt.signal });
if (!isLive(attempt)) return;
// loginViaWebmail resolves quietly when the user closes the browser —
// that's a cancellation, not a failure, so leave them where they were.
finishIfSignedIn(wasAuthenticated);
} catch (err) {
if (!isLive(attempt) || isSignInCancelled(err)) return;
if (continueWithSecondFactor(err, target)) return;
setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
if (endAttempt(attempt)) setBusy(null);
}
},
[continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaWebmail, t],
[beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaWebmail, t],
);
const handleEmailContinue = React.useCallback(async () => {
@@ -307,10 +348,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}
setNotice(null);
const attempt = beginAttempt();
setSearching(true);
rememberEmail(value);
try {
const found = await discoverServerForEmail(value, { knownServerUrls });
const found = await discoverServerForEmail(value, { knownServerUrls, signal: attempt.signal });
if (!isLive(attempt)) return;
if (found) {
setServerUrl(found);
setServerInput(found);
@@ -322,9 +365,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
goTo('server');
}
} finally {
setSearching(false);
if (endAttempt(attempt)) setSearching(false);
}
}, [email, goTo, knownServerUrls, rememberEmail, t]);
}, [beginAttempt, email, endAttempt, goTo, knownServerUrls, rememberEmail, t]);
const handleServerContinue = React.useCallback(() => {
const normalized = normalizeServerUrl(serverInput);
@@ -353,15 +396,18 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
return;
}
setNotice(null);
const attempt = beginAttempt();
setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginWithToken(target, accessToken, { addAccount: isAddMode });
await loginWithToken(target, accessToken, { addAccount: isAddMode, signal: attempt.signal });
if (!isLive(attempt)) return;
finishIfSignedIn(wasAuthenticated);
} catch (err) {
if (!isLive(attempt) || isSignInCancelled(err)) return;
setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
if (endAttempt(attempt)) setBusy(null);
}
return;
}
@@ -377,15 +423,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}
setNotice(null);
const attempt = beginAttempt();
setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await login(target, email.trim(), password, {
addAccount: isAddMode,
totp: totpRequired ? totp.trim() : undefined,
signal: attempt.signal,
});
if (!isLive(attempt)) return;
finishIfSignedIn(wasAuthenticated);
} catch (err) {
if (!isLive(attempt) || isSignInCancelled(err)) return;
if (err instanceof Error && err.name === 'TotpRequiredError') {
setTotpRequired(true);
setNotice(describeLoginError(err, { serverUrl: target, t }));
@@ -393,9 +443,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}
setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
if (endAttempt(attempt)) setBusy(null);
}
}, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]);
}, [accessToken, beginAttempt, email, endAttempt, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]);
// A sign-in code, however it arrived: scanned, pasted, or a tapped
// `bulwarkmail://` link.
@@ -415,23 +465,28 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// Cross-device pairing code: redeem it, no browser needed.
setNotice(null);
const attempt = beginAttempt();
setPairingWebmailUrl(payload.webmailUrl);
setBusy('pairing');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode });
await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode, signal: attempt.signal });
if (!isLive(attempt)) return;
finishIfSignedIn(wasAuthenticated);
} catch (err) {
if (!isLive(attempt) || isSignInCancelled(err)) return;
if (continueWithSecondFactor(err, payload.webmailUrl)) return;
// The error names its own host: the webmail for a failed redeem, the
// mail server when connecting afterwards failed.
setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t }));
} finally {
setBusy(null);
setPairingWebmailUrl(null);
if (endAttempt(attempt)) {
setBusy(null);
setPairingWebmailUrl(null);
}
}
},
[continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
[beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t],
);
const handleScanned = React.useCallback(
@@ -544,6 +599,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
phase={busy}
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
email={pairing || tokenMode ? null : email.trim() || null}
onCancel={cancelSignIn}
/>
);
}
@@ -565,7 +621,11 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
const canGoBack = history.length > 0;
const shellProps = {
onBack: canGoBack ? goBack : undefined,
// Back during the server search stops it as well (U-133).
onBack: canGoBack ? () => {
if (searching) cancelSignIn();
goBack();
} : undefined,
onClose: isAddMode && onCancel ? onCancel : undefined,
};
+26 -2
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native';
import { View, Text, StyleSheet, ActivityIndicator, Image, Pressable } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens';
import { useColors, useResolvedTheme } from '../../theme/colors';
@@ -16,6 +16,8 @@ interface SigningInStepProps {
phase: SigningInPhase;
serverUrl: string | null;
email: string | null;
/** Stops the sign-in and goes back to the step it started from (U-133). */
onCancel?: () => void;
}
function hostOf(url: string | null): string {
@@ -32,7 +34,7 @@ function initialsOf(email: string): string {
* current stage makes a slow first connection legible instead of a bare
* spinner that could mean anything.
*/
export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) {
export default function SigningInStep({ phase, serverUrl, email, onCancel }: SigningInStepProps) {
const c = useColors();
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -68,6 +70,18 @@ export default function SigningInStep({ phase, serverUrl, email }: SigningInStep
<Text style={styles.detail}>{copy[phase].detail}</Text>
<ActivityIndicator size="small" color={c.primary} style={styles.spinner} />
{onCancel ? (
<Pressable
onPress={onCancel}
hitSlop={8}
style={({ pressed }) => [styles.cancel, pressed && styles.cancelPressed]}
accessibilityRole="button"
accessibilityLabel={t('login.mobile.cancel_sign_in', 'Cancel sign-in')}
>
<Text style={styles.cancelText}>{t('common.cancel', 'Cancel')}</Text>
</Pressable>
) : null}
</View>
</SafeAreaView>
);
@@ -98,5 +112,15 @@ function makeStyles(c: ThemePalette) {
title: { ...typography.h2, color: c.text, textAlign: 'center' },
detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' },
spinner: { marginTop: spacing.xl },
cancel: {
marginTop: spacing.lg,
minHeight: componentSizes.buttonLg,
paddingHorizontal: spacing.lg,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.md,
},
cancelPressed: { opacity: 0.6 },
cancelText: { ...typography.bodyMedium, color: c.primary },
});
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { loginBackAction, type LoginBackState } from '../login-back';
const idle: LoginBackState = {
busy: false,
searching: false,
scannerVisible: false,
pasteVisible: false,
historyLength: 2,
canLeave: true,
};
describe('Back on the sign-in screen', () => {
it('cancels a sign-in under way instead of swallowing the key (U-133)', () => {
expect(loginBackAction({ ...idle, busy: true })).toBe('cancel');
});
it('cancels the server search', () => {
expect(loginBackAction({ ...idle, searching: true })).toBe('cancel');
});
it('closes the scanner, then the paste dialog, then goes a step back', () => {
expect(loginBackAction({ ...idle, scannerVisible: true })).toBe('close-scanner');
expect(loginBackAction({ ...idle, pasteVisible: true })).toBe('close-paste');
expect(loginBackAction(idle)).toBe('step-back');
});
it('leaves add-account from the first step, and lets the system have the key otherwise', () => {
expect(loginBackAction({ ...idle, historyLength: 0 })).toBe('leave');
expect(loginBackAction({ ...idle, historyLength: 0, canLeave: false })).toBe('exit');
});
});
+26
View File
@@ -0,0 +1,26 @@
// What Android Back (and the on-screen back) does on the sign-in screen.
// While a sign-in or the server search runs, Back cancels it and returns to
// the step it started from (U-133); it used to be swallowed, which left a
// hung "Signing in" with no way out but killing the app.
export type LoginBackAction = 'cancel' | 'close-scanner' | 'close-paste' | 'step-back' | 'leave' | 'exit';
export interface LoginBackState {
busy: boolean;
searching: boolean;
scannerVisible: boolean;
pasteVisible: boolean;
/** Steps behind the current one. */
historyLength: number;
/** Add-account mode with a way to close the screen. */
canLeave: boolean;
}
export function loginBackAction(state: LoginBackState): LoginBackAction {
if (state.busy || state.searching) return 'cancel';
if (state.scannerVisible) return 'close-scanner';
if (state.pasteVisible) return 'close-paste';
if (state.historyLength > 0) return 'step-back';
if (state.canLeave) return 'leave';
return 'exit';
}
@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// U-133: a sign-in that hangs had no way out. The sign-in screen now hands
// the store an AbortSignal; once it fires, the sign-in rejects at once, the
// client goes back to what it served before, and nothing of the account the
// sign-in was adding stays behind, even when the connect finishes later.
let finishConnect: ((value: unknown) => void) | null = null;
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connect: vi.fn(() => new Promise((resolve) => { finishConnect = resolve; })),
snapshot: vi.fn(() => ({ session: 'previous', credentials: null, accountId: 'prev' })),
restoreSnapshot: vi.fn(),
reset: vi.fn(),
clearAccountCredentials: vi.fn(async () => undefined),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
},
AuthenticationError: class AuthenticationError extends Error {},
InvalidTokenError: class InvalidTokenError extends Error {},
NetworkError: class NetworkError extends Error {},
SessionSupersededError: class SessionSupersededError extends Error {},
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { jmapClient } from '../../api/jmap-client';
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
const SERVER = 'https://mail.example.com';
beforeEach(() => {
vi.clearAllMocks();
finishConnect = null;
useAccountStore.setState({ accounts: [], activeAccountId: null });
useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null });
});
async function connectStarted(): Promise<void> {
await vi.waitFor(() => expect(finishConnect).not.toBeNull());
}
describe('cancelling a sign-in', () => {
it('rejects at once while the server has not answered, and keeps no error', async () => {
const controller = new AbortController();
const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal });
await connectStarted();
controller.abort();
await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' });
expect(useAuthStore.getState()).toMatchObject({ isLoading: false, error: null, isAuthenticated: false });
// The client must not go on serving the half-connected account.
expect(jmapClient.reset).toHaveBeenCalled();
expect(useAccountStore.getState().accounts).toEqual([]);
});
it('drops the credentials a connect stores after the user cancelled', async () => {
const controller = new AbortController();
const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal });
await connectStarted();
controller.abort();
await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' });
finishConnect!({ apiUrl: `${SERVER}/jmap/` });
await vi.waitFor(() => {
expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith('ada@example.com@mail.example.com');
});
expect(useAccountStore.getState().accounts).toEqual([]);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
it('puts the account in use back when adding one is cancelled', async () => {
useAuthStore.setState({ isAuthenticated: true });
const controller = new AbortController();
const pending = useAuthStore.getState().login(SERVER, 'bob@example.com', 'pw', {
addAccount: true,
signal: controller.signal,
});
await connectStarted();
controller.abort();
await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' });
expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith({ session: 'previous', credentials: null, accountId: 'prev' });
expect(useAuthStore.getState().isAuthenticated).toBe(true);
});
it('does not start when the signal fired before', async () => {
const controller = new AbortController();
controller.abort();
await expect(
useAuthStore.getState().loginWithToken(SERVER, 'tok', { signal: controller.signal }),
).rejects.toMatchObject({ name: 'SignInCancelledError' });
expect(useAuthStore.getState().error).toBeNull();
});
});
@@ -132,7 +132,7 @@ describe('loginViaPairing with an app password', () => {
await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode());
expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined);
expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined, undefined);
expect(connectWithOAuth).not.toHaveBeenCalled();
expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER });
});
+81 -30
View File
@@ -41,6 +41,7 @@ import {
import { reconcileServerFingerprint } from '../lib/server-change';
import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle';
import { perfMark } from '../lib/perf-marks';
import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort';
// Persist middleware hydrates asynchronously on cold start. Without this
// guard, restoreSession() can read the account-store before AsyncStorage has
@@ -97,14 +98,14 @@ export interface AuthState {
serverUrl: string,
username: string,
password: string,
opts?: { addAccount?: boolean; totp?: string },
opts?: SignInOptions & { totp?: string },
) => Promise<void>;
/** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */
loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginWithToken: (serverUrl: string, token: string, opts?: SignInOptions) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: SignInOptions) => Promise<void>;
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaOAuth: (serverUrl: string, opts?: SignInOptions) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: SignInOptions) => Promise<void>;
logout: () => Promise<void>;
logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>;
@@ -259,6 +260,16 @@ async function switchesSettled(): Promise<void> {
while (pendingSwitch) await pendingSwitch.settled;
}
/**
* `signal` is the sign-in screen's Cancel (U-133): once it fires, the sign-in
* rejects with SignInCancelledError, puts the connection in use back and
* keeps nothing of the account it was adding.
*/
export interface SignInOptions {
addAccount?: boolean;
signal?: AbortSignal;
}
interface SignedIn {
session: JMAPSession;
serverUrl: string;
@@ -276,12 +287,14 @@ interface SignedIn {
async function signIn(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
opts: { addAccount?: boolean } | undefined,
opts: SignInOptions | undefined,
connect: () => Promise<SignedIn>,
): Promise<SignedIn> {
signInsUnderWay += 1;
let saved: typeof signInSaved = null;
const signal = opts?.signal;
try {
throwIfCancelled(signal);
// A switch under way lands first, so there is one account to put back.
await switchesSettled();
const isCurrent = beginAccountChange();
@@ -304,7 +317,23 @@ async function signIn(
let wasRegistered = true;
const accountStore = useAccountStore.getState();
try {
signedIn = await connect();
const connecting = connect();
if (signal) {
// Cancelled while connecting: the connect goes on in the background
// until its next session check (putting the connection back below
// supersedes it). Should it get as far as storing credentials for
// the account anyway, they go too.
connecting.then(
(late) => {
if (!signal.aborted || signedIn) return;
if (!useAccountStore.getState().getAccountById(late.accountId)) {
void jmapClient.clearAccountCredentials(late.accountId).catch(() => undefined);
}
},
() => undefined,
);
}
signedIn = await abortable(connecting, signal);
if (!isCurrent()) throw new SessionSupersededError();
wasRegistered = !!accountStore.getAccountById(signedIn.accountId);
// Contacts/calendar are still single-bucket, so wipe those now that
@@ -315,6 +344,7 @@ async function signIn(
}
await checkServerChange(signedIn.accountId, signedIn.session);
if (!isCurrent()) throw new SessionSupersededError();
throwIfCancelled(signal);
accountStore.addAccount({
serverUrl: signedIn.serverUrl,
username: signedIn.username,
@@ -325,7 +355,8 @@ async function signIn(
hasError: false,
});
} catch (err) {
putBack(!!signedIn);
// A cancelled connect may already hold the client: take it back.
putBack(!!signedIn || isSignInCancelled(err));
// connect() stored credentials for an account the registry never took
// (the account limit, or a newer account change): none stay behind.
if (signedIn && !wasRegistered && !useAccountStore.getState().getAccountById(signedIn.accountId)) {
@@ -394,7 +425,7 @@ function assertRoomForAccount(accountId?: string): void {
// Adding an account to a full registry: refuse before a sign-in code is
// spent or a browser sign-in started, since what they buy can't be kept.
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: { addAccount?: boolean }): void {
function refuseAddWhenFull(set: (partial: Partial<AuthState>) => void, opts?: SignInOptions): void {
if (!opts?.addAccount) return;
try {
assertRoomForAccount();
@@ -463,7 +494,7 @@ async function completeOAuthHandoff(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
opts?: SignInOptions,
): Promise<void> {
await signIn(set, get, opts, async () => {
const { session, username, accountId } = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
@@ -471,6 +502,23 @@ async function completeOAuthHandoff(
});
}
// After an await of a sign-in step that takes no signal: stop when the user
// cancelled meanwhile.
function cancelledSignIn(set: (partial: Partial<AuthState>) => void, opts?: SignInOptions): void {
if (!opts?.signal?.aborted) return;
set({ isLoading: false, error: null });
throwIfCancelled(opts.signal);
}
function oauthSignInMessage(err: unknown): string | null {
if (isSignInCancelled(err)) return null;
return err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth sign-in failed';
}
function applyConnectedState(
set: (partial: Partial<AuthState>) => void,
session: JMAPSession,
@@ -514,12 +562,16 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Adding an additional account keeps the live connection until the
// new sign-in succeeded, so a typo doesn't kill the current session.
await signIn(set, get, opts, async () => {
const session = await jmapClient.connect(serverUrl, username, password, opts?.totp);
const session = await jmapClient.connect(serverUrl, username, password, opts?.totp, opts?.signal);
const base = serverUrl.replace(/\/+$/, '');
return { session, serverUrl: base, username, accountId: generateAccountId(username, base) };
});
set({ pendingTotpLogin: null });
} catch (err) {
if (isSignInCancelled(err)) {
set({ isLoading: false, error: null });
throw err;
}
if (err instanceof Error && err.name === 'TotpRequiredError') {
// Keep what the user (or the webmail hand-off) supplied so the code
// step doesn't make them retype the password.
@@ -550,7 +602,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
set({ pendingTotpLogin: null });
} catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' });
set({ isLoading: false, error: isSignInCancelled(err) ? null : err instanceof Error ? err.message : 'Connection failed' });
throw err;
}
},
@@ -562,8 +614,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart
// lands on a 404 or the admin page, so check first and fall back to the
// server's own OAuth (PKCE) when the webmail is missing.
if (!(await probeWebmail(webmailUrl))) {
const hasWebmail = await probeWebmail(webmailUrl);
cancelledSignIn(set, opts);
if (!hasWebmail) {
const metadata = await discoverOAuthMetadata(webmailUrl);
cancelledSignIn(set, opts);
if (metadata) {
await get().loginViaOAuth(webmailUrl, opts);
return;
@@ -575,12 +630,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
let result;
try {
result = await runWebmailHandoff(webmailUrl, { addAccount: opts?.addAccount });
cancelledSignIn(set, opts);
} catch (err) {
if (err instanceof HandoffCancelledError) {
// User closed the browser tab — quiet exit, no error banner.
set({ isLoading: false, error: null });
return;
}
if (isSignInCancelled(err)) throw err;
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
@@ -600,13 +657,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try {
await completeOAuthHandoff(set, get, result, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth sign-in failed';
set({ isLoading: false, error: message });
set({ isLoading: false, error: oauthSignInMessage(err) });
throw err;
}
},
@@ -620,11 +671,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const metadata = await discoverOAuthMetadata(base);
if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in');
tokens = await loginWithPkce(base, metadata, { addAccount: opts?.addAccount });
cancelledSignIn(set, opts);
} catch (err) {
if (err instanceof HandoffCancelledError) {
set({ isLoading: false, error: null });
return;
}
if (isSignInCancelled(err)) throw err;
const message = err instanceof Error ? err.message : 'Sign-in failed';
set({ isLoading: false, error: message });
throw err;
@@ -632,13 +685,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
try {
await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts);
} catch (err) {
const message =
err instanceof AuthenticationError
? 'Authentication rejected by server'
: err instanceof Error
? err.message
: 'OAuth sign-in failed';
set({ isLoading: false, error: message });
set({ isLoading: false, error: oauthSignInMessage(err) });
throw err;
}
},
@@ -660,13 +707,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
let result: HandoffResult;
try {
result = await redeemPairingCode(webmailUrl, code);
result = await abortable(redeemPairingCode(webmailUrl, code), opts?.signal);
} catch (err) {
// Not redeemed here: the server is the judge of a retry (it answers
// "used" if the request did get through).
pairingCodesSeen.delete(code);
const message = err instanceof Error ? err.message : 'Pairing failed';
set({ isLoading: false, error: message });
set({ isLoading: false, error: isSignInCancelled(err) ? null : message });
throw err;
}
@@ -677,6 +724,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// keeps for that step), and the account limit, which a new code won't fix.
const serverHost = hostOfUrl(result.serverUrl);
const connectFailed = (err: unknown): unknown => {
if (isSignInCancelled(err)) {
set({ isLoading: false, error: null });
return err;
}
if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) {
// `login` has settled the store already; the OAuth path has not.
if (get().isLoading) set({ isLoading: false, error: err.message });
@@ -700,7 +751,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// password): the normal password sign-in, which also keeps the live
// account when adding another one fails.
try {
await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount });
await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount, signal: opts?.signal });
} catch (err) {
throw connectFailed(err);
}