fix: keep the live session on a failed add or switch, recover from revoked sessions, refresh display names and sign in with a TOTP code (#900)

This commit is contained in:
Linus Rath
2026-08-29 17:57:32 +02:00
parent e70e57bf47
commit b52768e6fb
6 changed files with 223 additions and 29 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) {
<View style={styles.hintWrap}> <View style={styles.hintWrap}>
<Text style={styles.hintTitle}>Open Bulwark on your computer</Text> <Text style={styles.hintTitle}>Open Bulwark on your computer</Text>
<Text style={styles.hint}> <Text style={styles.hint}>
Settings → Devices → Add phone shows a code. Point the camera at it. Settings → Security → Link device shows a code. Point the camera at it.
</Text> </Text>
</View> </View>
</View> </View>
+39
View File
@@ -36,6 +36,38 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
const lower = message.toLowerCase(); const lower = message.toLowerCase();
const host = hostLabel(context.serverUrl); const host = hostLabel(context.serverUrl);
if (name === 'TotpRequiredError' || message === 'TOTP_REQUIRED' || lower.includes('two-factor code required')) {
return {
title: 'Enter your two-factor code',
detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
};
}
if (name === 'TotpLoginError' && lower.includes('invalid')) {
return {
title: "That didn't work",
detail: 'Check your password and the current code from your authenticator app, then try again.',
};
}
if (name === 'RateLimitError' || /\b429\b/.test(message) || lower.includes('rate limited')) {
const retryMs = (err as { retryAfterMs?: number } | null)?.retryAfterMs;
const seconds = typeof retryMs === 'number' && Number.isFinite(retryMs) ? Math.max(1, Math.round(retryMs / 1000)) : null;
return {
title: 'Too many attempts',
detail: seconds
? `The server asked us to wait. Try again in about ${seconds} seconds.`
: 'The server asked us to wait a moment. Try again shortly.',
};
}
if (lower.includes('session discovery failed') && /\b402\b/.test(message)) {
return {
title: 'Enter your two-factor code',
detail: 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.',
};
}
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) { if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return { return {
title: "That didn't work", title: "That didn't work",
@@ -76,6 +108,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
}; };
} }
if (lower.includes('session discovery failed') && /\b5\d\d\b/.test(message)) {
return {
title: `${host} is having trouble`,
detail: 'The server answered with an error. Try again in a few minutes.',
};
}
if (lower.includes('pairing code')) { if (lower.includes('pairing code')) {
return { return {
title: 'That code has expired', title: 'That code has expired',
+26 -3
View File
@@ -55,6 +55,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// when discovery actually found one. // when discovery actually found one.
const [serverDiscovered, setServerDiscovered] = React.useState(false); const [serverDiscovered, setServerDiscovered] = React.useState(false);
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
// Second factor. Shown only after the server asked for it (402), so the
// common no-2FA sign-in stays a two-field form.
const [totp, setTotp] = React.useState('');
const [totpRequired, setTotpRequired] = React.useState(false);
const [failedDomain, setFailedDomain] = React.useState<string | null>(null); const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null); const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
const [searching, setSearching] = React.useState(false); const [searching, setSearching] = React.useState(false);
@@ -198,18 +202,31 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
return; return;
} }
if (totpRequired && !/^\d{6,8}$/.test(totp.trim())) {
setNotice({ title: 'Enter the 6-digit code from your authenticator app' });
return;
}
setNotice(null); setNotice(null);
setBusy('connecting'); setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated; const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try { try {
await login(target, email.trim(), password, { addAccount: isAddMode }); await login(target, email.trim(), password, {
addAccount: isAddMode,
totp: totpRequired ? totp.trim() : undefined,
});
finishIfSignedIn(wasAuthenticated); finishIfSignedIn(wasAuthenticated);
} catch (err) { } catch (err) {
if (err instanceof Error && err.name === 'TotpRequiredError') {
setTotpRequired(true);
setNotice(describeLoginError(err, { serverUrl: target }));
return;
}
setNotice(describeLoginError(err, { serverUrl: target })); setNotice(describeLoginError(err, { serverUrl: target }));
} finally { } finally {
setBusy(null); setBusy(null);
} }
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]); }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired]);
const handleScanned = React.useCallback( const handleScanned = React.useCallback(
async (data: string) => { async (data: string) => {
@@ -218,7 +235,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
if (!payload) { if (!payload) {
setNotice({ setNotice({
title: "That code isn't a Bulwark sign-in code", title: "That code isn't a Bulwark sign-in code",
detail: 'Open Bulwark on the web, then Settings → Devices to show one.', detail: 'Open Bulwark on the web, then Settings → Security → Link device to show one.',
}); });
return; return;
} }
@@ -364,6 +381,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setNotice(null); setNotice(null);
setPassword(value); setPassword(value);
}} }}
totp={totp}
totpRequired={totpRequired}
onChangeTotp={(value) => {
setNotice(null);
setTotp(value.replace(/\s+/g, ''));
}}
onSubmit={() => void handlePasswordSubmit()} onSubmit={() => void handlePasswordSubmit()}
notice={notice} notice={notice}
/> />
+25 -2
View File
@@ -12,6 +12,10 @@ interface PasswordStepProps {
password: string; password: string;
onChangeEmail: (value: string) => void; onChangeEmail: (value: string) => void;
onChangePassword: (value: string) => void; onChangePassword: (value: string) => void;
/** Second factor, shown once the server answered 402 "MFA code required". */
totp?: string;
totpRequired?: boolean;
onChangeTotp?: (value: string) => void;
onSubmit: () => void; onSubmit: () => void;
notice?: { title: string; detail?: string } | null; notice?: { title: string; detail?: string } | null;
} }
@@ -28,6 +32,9 @@ export default function PasswordStep({
password, password,
onChangeEmail, onChangeEmail,
onChangePassword, onChangePassword,
totp = '',
totpRequired = false,
onChangeTotp,
onSubmit, onSubmit,
notice, notice,
}: PasswordStepProps) { }: PasswordStepProps) {
@@ -65,8 +72,8 @@ export default function PasswordStep({
secureTextEntry={!showPassword} secureTextEntry={!showPassword}
autoComplete="current-password" autoComplete="current-password"
textContentType="password" textContentType="password"
returnKeyType="go" returnKeyType={totpRequired ? 'next' : 'go'}
onSubmitEditing={onSubmit} onSubmitEditing={totpRequired ? undefined : onSubmit}
rightIcon={ rightIcon={
<Pressable <Pressable
onPress={() => setShowPassword((v) => !v)} onPress={() => setShowPassword((v) => !v)}
@@ -79,6 +86,22 @@ export default function PasswordStep({
} }
/> />
{totpRequired ? (
<Input
label="Two-factor code"
placeholder="123456"
value={totp}
onChangeText={(value) => onChangeTotp?.(value)}
autoFocus
keyboardType="number-pad"
autoComplete="one-time-code"
textContentType="oneTimeCode"
maxLength={8}
returnKeyType="go"
onSubmitEditing={onSubmit}
/>
) : null}
{notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null} {notice ? <LoginNotice title={notice.title} detail={notice.detail} /> : null}
<Button variant="default" size="md" onPress={onSubmit}> <Button variant="default" size="md" onPress={onSubmit}>
+6
View File
@@ -10,6 +10,12 @@ vi.mock('../../api/jmap-client', () => ({
clearAccountCredentials: vi.fn(async () => undefined), clearAccountCredentials: vi.fn(async () => undefined),
clearAllCredentials: vi.fn(async () => undefined), clearAllCredentials: vi.fn(async () => undefined),
reset: vi.fn(), reset: vi.fn(),
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
restoreSnapshot: vi.fn(),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
hasAccountCapability: vi.fn(() => false),
request: vi.fn(async () => { throw new Error('not mocked'); }),
accountId: 'acc-1', accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
username: 'user', username: 'user',
+126 -23
View File
@@ -1,6 +1,8 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client'; import { jmapClient, AuthenticationError, NetworkError } from '../api/jmap-client';
import type { JMAPSession } from '../api/types'; import type { JMAPSession } from '../api/types';
import { getIdentities } from '../api/identity';
import { fetchPrincipal, isStalwartSupported } from '../api/account-security';
import { useAccountStore } from './account-store'; import { useAccountStore } from './account-store';
import { useEmailStore } from './email-store'; import { useEmailStore } from './email-store';
import { useContactsStore } from './contacts-store'; import { useContactsStore } from './contacts-store';
@@ -39,12 +41,19 @@ export interface AuthState {
activeAccountId: string | null; activeAccountId: string | null;
client: typeof jmapClient | null; client: typeof jmapClient | null;
login: (serverUrl: string, username: string, password: string, opts?: { addAccount?: boolean }) => Promise<void>; login: (
serverUrl: string,
username: string,
password: string,
opts?: { addAccount?: boolean; totp?: string },
) => Promise<void>;
loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>; loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>; loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
logoutAll: () => Promise<void>; logoutAll: () => Promise<void>;
switchAccount: (accountId: string) => Promise<void>; switchAccount: (accountId: string) => Promise<void>;
/** Sign a non-active account out and drop its caches; the active one stays. */
removeAccount: (accountId: string) => Promise<void>;
restoreSession: () => Promise<boolean>; restoreSession: () => Promise<boolean>;
retrySession: () => Promise<boolean>; retrySession: () => Promise<boolean>;
clearError: () => void; clearError: () => void;
@@ -98,6 +107,38 @@ function refetchFeatureStores(): void {
} }
} }
// Refresh the registry's display name / address from the server (#900): the
// primary identity first, then the Stalwart principal's "Full name" when the
// account advertises the extension. Fire-and-forget; a failure keeps whatever
// the registry already had.
async function syncAccountDisplayName(accountId: string): Promise<void> {
try {
const accountStore = useAccountStore.getState();
const entry = accountStore.getAccountById(accountId);
if (!entry) return;
const updates: { displayName?: string; email?: string } = {};
const identities = await getIdentities().catch(() => []);
const primary = identities.find((i) => i.email?.toLowerCase() === entry.email?.toLowerCase())
?? identities.find((i) => i.email?.toLowerCase() === entry.username?.toLowerCase())
?? identities[0];
if (primary?.name?.trim()) updates.displayName = primary.name.trim();
if (primary?.email && !entry.email.includes('@')) updates.email = primary.email;
if (isStalwartSupported()) {
const principal = await fetchPrincipal().catch(() => null);
if (principal?.displayName?.trim()) updates.displayName = principal.displayName.trim();
if (!updates.email && principal?.emails[0] && !entry.email.includes('@')) {
updates.email = principal.emails[0];
}
}
if (Object.keys(updates).length === 0) return;
if (useAccountStore.getState().getAccountById(accountId)) {
useAccountStore.getState().updateAccount(accountId, updates);
}
} catch {
// cosmetic - never block sign-in on it
}
}
// Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR // Shared tail of the OAuth sign-in flows (browser handoff and cross-device QR
// pairing both end here). Bootstraps a JMAP session from the token bundle, // pairing both end here). Bootstraps a JMAP session from the token bundle,
// registers the account, and flips the store to connected. Throws on failure // registers the account, and flips the store to connected. Throws on failure
@@ -108,17 +149,24 @@ async function completeOAuthHandoff(
result: Extract<HandoffResult, { flow: 'oauth' }>, result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean }, opts?: { addAccount?: boolean },
): Promise<void> { ): Promise<void> {
if (opts?.addAccount && get().isAuthenticated) { // Adding an account must not destroy the live one: the singleton keeps the
jmapClient.reset(); // previous connection until the new sign-in has actually succeeded, and a
// failure puts it straight back.
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
let connected: { session: JMAPSession; username: string; accountId: string };
try {
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
} catch (err) {
if (previous) jmapClient.restoreSnapshot(previous);
throw err;
}
const { session, username, accountId } = connected;
if (previous) {
useContactsStore.getState().reset(); useContactsStore.getState().reset();
useCalendarStore.getState().reset(); useCalendarStore.getState().reset();
} }
const { session, username, accountId } = await jmapClient.connectWithOAuth(
result.serverUrl,
result.tokens,
);
const accountStore = useAccountStore.getState(); const accountStore = useAccountStore.getState();
accountStore.addAccount({ accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''), serverUrl: result.serverUrl.replace(/\/+$/, ''),
@@ -133,6 +181,7 @@ async function completeOAuthHandoff(
useEmailStore.getState().setActiveAccount(accountId); useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId); applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
void syncAccountDisplayName(accountId);
} }
function applyConnectedState( function applyConnectedState(
@@ -170,17 +219,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
login: async (serverUrl, username, password, opts) => { login: async (serverUrl, username, password, opts) => {
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
// Adding an additional account: keep the live connection until the new
// sign-in succeeded so a typo doesn't kill the current session.
const previous = opts?.addAccount && get().isAuthenticated ? jmapClient.snapshot() : null;
try { try {
// Adding an additional account - snapshot the current account away so let session: JMAPSession;
// its cache survives, then reset the JMAP client for the new login. try {
// Contacts/calendar are still single-bucket, so wipe those. session = await jmapClient.connect(serverUrl, username, password, opts?.totp);
if (opts?.addAccount && get().isAuthenticated) { } catch (err) {
jmapClient.reset(); if (previous) jmapClient.restoreSnapshot(previous);
throw err;
}
// Contacts/calendar are still single-bucket, so wipe those now that
// the new account is the one the client serves.
if (previous) {
useContactsStore.getState().reset(); useContactsStore.getState().reset();
useCalendarStore.getState().reset(); useCalendarStore.getState().reset();
} }
const session = await jmapClient.connect(serverUrl, username, password);
const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, '')); const accountId = generateAccountId(username, serverUrl.replace(/\/+$/, ''));
const accountStore = useAccountStore.getState(); const accountStore = useAccountStore.getState();
@@ -199,12 +254,15 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useEmailStore.getState().setActiveAccount(accountId); useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId); applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
void syncAccountDisplayName(accountId);
} catch (err) { } catch (err) {
const message = err instanceof AuthenticationError const message = err instanceof Error && err.name === 'TotpRequiredError'
? 'Invalid username or password' ? 'Two-factor code required'
: err instanceof Error : err instanceof AuthenticationError
? err.message ? 'Invalid username or password'
: 'Connection failed'; : err instanceof Error
? err.message
: 'Connection failed';
set({ isLoading: false, error: message }); set({ isLoading: false, error: message });
throw err; throw err;
} }
@@ -384,13 +442,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// jmapClient first. If it fails, restore the previous active account // jmapClient first. If it fails, restore the previous active account
// so we don't leave the user stranded on a half-switched state. // so we don't leave the user stranded on a half-switched state.
const previousActive = get().activeAccountId; const previousActive = get().activeAccountId;
// loadAccount overwrites the client's credentials/session; keep the live
// connection around so a failed switch can put it back instead of
// leaving the previous account dead until relaunch.
const previousClient = jmapClient.snapshot();
const restorePrevious = () => {
jmapClient.restoreSnapshot(previousClient);
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive);
};
try { try {
const ok = await jmapClient.loadAccount(accountId); const ok = await jmapClient.loadAccount(accountId);
if (!ok) { if (!ok) {
// Credentials missing - evict stale entry and surface error // Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId); accountStore.removeAccount(accountId);
useEmailStore.getState().removeAccount(accountId); useEmailStore.getState().removeAccount(accountId);
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); restorePrevious();
set({ isLoading: false, error: 'Session expired for this account' }); set({ isLoading: false, error: 'Session expired for this account' });
return; return;
} }
@@ -399,13 +465,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined); await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
accountStore.removeAccount(accountId); accountStore.removeAccount(accountId);
useEmailStore.getState().removeAccount(accountId); useEmailStore.getState().removeAccount(accountId);
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); restorePrevious();
set({ isLoading: false, error: 'Session expired for this account' }); set({ isLoading: false, error: 'Session expired for this account' });
return; return;
} }
// NetworkError or anything else - keep the previous active account // NetworkError or anything else - keep the previous active account
// intact instead of stranding the user on a half-switched state. // intact instead of stranding the user on a half-switched state.
if (previousActive) useEmailStore.getState().setActiveAccount(previousActive); restorePrevious();
accountStore.updateAccount(accountId, {
hasError: true,
errorMessage: err instanceof Error ? err.message : 'Failed to switch account',
});
set({ set({
isLoading: false, isLoading: false,
error: err instanceof Error ? err.message : 'Failed to switch account', error: err instanceof Error ? err.message : 'Failed to switch account',
@@ -429,6 +499,20 @@ export const useAuthStore = create<AuthState>((set, get) => ({
applyConnectedState(set, session, target.serverUrl, target.username, accountId); applyConnectedState(set, session, target.serverUrl, target.username, accountId);
refetchFeatureStores(); refetchFeatureStores();
void syncAccountDisplayName(accountId);
},
removeAccount: async (accountId) => {
if (get().activeAccountId === accountId) {
await get().logout();
return;
}
const accountStore = useAccountStore.getState();
if (!accountStore.getAccountById(accountId)) return;
await teardownPushNotificationsForAccount(accountId).catch(() => undefined);
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
useEmailStore.getState().removeAccount(accountId);
accountStore.removeAccount(accountId);
}, },
restoreSession: async () => { restoreSession: async () => {
@@ -537,6 +621,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// is live. Feature stores show persisted data immediately; this swaps // is live. Feature stores show persisted data immediately; this swaps
// in fresh data once the network round-trip completes. // in fresh data once the network round-trip completes.
refetchFeatureStores(); refetchFeatureStores();
void syncAccountDisplayName(target.id);
return true; return true;
} catch { } catch {
set({ isLoading: false, hasRestoredSession: true }); set({ isLoading: false, hasRestoredSession: true });
@@ -592,3 +677,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
clearError: () => set({ error: null }), clearError: () => set({ error: null }),
})); }));
// A 401 on a live session (revoked password/token, expired refresh token)
// used to leave the user on a dead session until relaunch: nothing outside
// the login flow handled `AuthenticationError`, and `retrySession` bailed
// because `session` was still the stale object. Drop the session and retry
// once; if the credentials really are dead, `retrySession` evicts the
// account and shows the login screen with "Session expired".
let authFailureInFlight = false;
jmapClient.onAuthFailure(() => {
if (authFailureInFlight) return;
const state = useAuthStore.getState();
if (!state.isAuthenticated || !state.session) return;
authFailureInFlight = true;
useAuthStore.setState({ session: null });
void state.retrySession().finally(() => {
authFailureInFlight = false;
});
});