feat: sign in with an access token

This commit is contained in:
Linus Rath
2026-10-25 12:14:30 +01:00
parent c08e9d6fa8
commit b4331a9bf9
9 changed files with 407 additions and 71 deletions
+68 -2
View File
@@ -8,7 +8,7 @@ vi.mock('expo-secure-store', () => ({
}));
import * as SecureStore from 'expo-secure-store';
import { JMAPClient, AuthenticationError, RateLimitError, parseHoldLimit } from '../jmap-client';
import { JMAPClient, AuthenticationError, InvalidTokenError, RateLimitError, parseHoldLimit } from '../jmap-client';
import type { JMAPSession } from '../types';
const MOCK_SESSION: JMAPSession = {
@@ -113,8 +113,10 @@ describe('JMAPClient', () => {
});
describe('connectWithToken', () => {
const TOKEN_SESSION = { ...MOCK_SESSION, username: 'user@example.com' };
it('should use Bearer auth', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
await client.connectWithToken('https://mail.example.com', 'my-token');
@@ -128,6 +130,70 @@ describe('JMAPClient', () => {
);
expect(client.isConnected).toBe(true);
});
it('names the account after the session and remembers the token', async () => {
global.fetch = mockFetch([{ status: 200, json: TOKEN_SESSION }]) as any;
const { username, accountId } = await client.connectWithToken('https://mail.example.com/', 'my-token');
expect(username).toBe('user@example.com');
expect(accountId).toBe('user@example.com@mail.example.com');
const [key, stored] = vi.mocked(SecureStore.setItemAsync).mock.calls.at(-1)!;
expect(key).toContain('user_example.com');
expect(JSON.parse(stored)).toMatchObject({
serverUrl: 'https://mail.example.com',
username: 'user@example.com',
accessToken: 'my-token',
});
expect(client.usesPastedToken).toBe(true);
});
it('refuses a token the server rejects', async () => {
global.fetch = mockFetch([{ status: 401 }]) as any;
await expect(client.connectWithToken('https://mail.example.com', 'bad')).rejects.toThrow(InvalidTokenError);
expect(client.isConnected).toBe(false);
});
it('refuses a session that does not name the account', async () => {
global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any;
await expect(client.connectWithToken('https://mail.example.com', 'my-token')).rejects.toThrow('did not name the account');
expect(client.isConnected).toBe(false);
});
it('keeps a download host the server runs on another HTTPS origin', async () => {
global.fetch = mockFetch([{
status: 200,
json: {
...TOKEN_SESSION,
apiUrl: 'https://api.fastmail.com/jmap/api/',
downloadUrl: 'https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}',
uploadUrl: 'https://api.fastmail.com/jmap/upload/{accountId}/',
},
}]) as any;
const { session } = await client.connectWithToken('https://api.fastmail.com', 'my-token');
expect(session.downloadUrl).toBe('https://www.fastmailusercontent.com/jmap/download/{accountId}/{blobId}/{name}?type={type}');
expect(session.uploadUrl).toBe('https://api.fastmail.com/jmap/upload/{accountId}/');
});
it('still moves self-reported internal hosts onto the server address', async () => {
global.fetch = mockFetch([{
status: 200,
json: {
...TOKEN_SESSION,
apiUrl: 'http://localhost:8080/jmap/',
downloadUrl: 'http://localhost:8080/download/{accountId}/{blobId}/{name}?type={type}',
},
}]) as any;
const { session } = await client.connectWithToken('https://mail.example.com', 'my-token');
expect(session.apiUrl).toBe('https://mail.example.com/jmap/');
expect(session.downloadUrl).toBe('https://mail.example.com/download/{accountId}/{blobId}/{name}?type={type}');
});
});
describe('request', () => {
+57 -6
View File
@@ -177,6 +177,12 @@ export class JMAPClient {
return !!this.credentials?.accessToken;
}
// A pasted access token, as opposed to an OAuth bundle (which always names
// the token endpoint it renews at).
get usesPastedToken(): boolean {
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
}
get isConnected(): boolean {
return this.session !== null && this._accountId !== null;
}
@@ -294,15 +300,42 @@ export class JMAPClient {
return session;
}
async connectWithToken(serverUrl: string, accessToken: string): Promise<JMAPSession> {
/**
* Access-token login (an API token the user pasted, e.g. from Fastmail).
* The token is the whole credential: it names the account, so the username
* comes from the session, and with no refresh token it lasts until the
* provider expires or revokes it.
*/
async connectWithToken(
serverUrl: string,
accessToken: string,
): Promise<{ session: JMAPSession; username: string; accountId: string }> {
const baseUrl = serverUrl.replace(/\/+$/, '');
this.credentials = { serverUrl: baseUrl, username: '', password: '', accessToken };
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
try {
this.session = this.rewriteSessionUrls(await this.fetchSession(baseUrl), baseUrl);
} catch (err) {
this.credentials = null;
if (err instanceof AuthenticationError || (err instanceof Error && /^Session discovery failed: 403\b/.test(err.message))) {
throw new InvalidTokenError();
}
throw err;
}
this._accountId = this.resolveAccountId(this.session);
this.firstTouchGate.reset();
return this.session;
const username = this.session.username;
if (!username) {
this.reset();
throw new Error('The server did not name the account');
}
this.credentials.username = username;
const accountId = generateAccountId(username, baseUrl);
await writeSharedSecret(credentialsKey(accountId), JSON.stringify(this.credentials));
return { session: this.session, username, accountId };
}
// OAuth login via webmail handoff. The webmail did the OAuth dance against
@@ -612,16 +645,26 @@ export class JMAPClient {
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
//
// A URL on another HTTPS origin than the server's own apiUrl is one it
// deliberately hosts elsewhere (Fastmail serves downloads from a separate
// domain) and is kept; everything else moves onto the origin we connected to.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = extractOrigin(serverUrl);
const reportedOrigin = session.apiUrl ? extractOrigin(session.apiUrl) : null;
const rewrite = (url: string | undefined): string | undefined =>
rewriteSessionUrl(url, serverOrigin);
const rewriteHosted = (url: string | undefined): string | undefined => {
const origin = url ? extractOrigin(url) : null;
const hostedElsewhere = !!origin && !!reportedOrigin && origin !== reportedOrigin && /^https:/i.test(origin);
return hostedElsewhere ? url : rewrite(url);
};
return {
...session,
apiUrl: rewrite(session.apiUrl) ?? session.apiUrl,
downloadUrl: rewrite(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewrite(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewrite(session.eventSourceUrl) ?? session.eventSourceUrl,
downloadUrl: rewriteHosted(session.downloadUrl) ?? session.downloadUrl,
uploadUrl: rewriteHosted(session.uploadUrl) ?? session.uploadUrl,
eventSourceUrl: rewriteHosted(session.eventSourceUrl) ?? session.eventSourceUrl,
};
}
@@ -1283,6 +1326,14 @@ export class AuthenticationError extends Error {
}
}
// The server refused a pasted access token (wrong, expired or revoked).
export class InvalidTokenError extends Error {
constructor() {
super('Invalid access token');
this.name = 'InvalidTokenError';
}
}
// The server wants a second factor before it will hand out a session.
export class TotpRequiredError extends AuthenticationError {
constructor() {
+6 -2
View File
@@ -52,7 +52,9 @@ export function AccountSettings() {
const username = account?.username || authUsername || undefined;
const serverUrl = account?.serverUrl || authServerUrl || '';
// Live client state, not a prop: OAuth/handoff accounts carry a bearer token.
const authMode: 'oauth' | 'basic' = jmapClient.usesBearerAuth ? 'oauth' : 'basic';
const authMode: 'oauth' | 'token' | 'basic' = jmapClient.usesPastedToken
? 'token'
: jmapClient.usesBearerAuth ? 'oauth' : 'basic';
const [quota, setQuota] = useState<MailQuota | null>(null);
useEffect(() => {
@@ -128,7 +130,9 @@ export function AccountSettings() {
<Text style={styles.value}>
{authMode === 'oauth'
? t('settings.account.auth_method_oauth', 'Single Sign-On (OAuth/OIDC)')
: t('settings.account.auth_method_basic', 'Password')}
: authMode === 'token'
? t('settings.account.auth_method_token', 'Access token')
: t('settings.account.auth_method_basic', 'Password')}
</Text>
</SettingItem>
+8
View File
@@ -16,6 +16,14 @@ describe('describeLoginError', () => {
expect(copy.detail).toMatch(/app password/i);
});
it('blames a refused access token on the token, not on a password', () => {
for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) {
const copy = describeLoginError(err);
expect(copy.detail).toMatch(/access token/);
expect(copy.detail).not.toMatch(/password/i);
}
});
it('explains the auth store\'s "Session expired" notice', () => {
expect(describeLoginError('Session expired').title).toBe('Your session has expired. Please sign in again.');
expect(describeLoginError('Session expired for this account').title).toBe('Your session has expired. Please sign in again.');
+7
View File
@@ -151,6 +151,13 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
};
}
if (name === 'InvalidTokenError' || lower.includes('invalid access token')) {
return {
title: t('login.mobile.err_bad_title', "That didn't work"),
detail: t('login.error.invalid_token', 'The server did not accept this access token. Check that it is complete and has not expired or been revoked.'),
};
}
if (name === 'AuthenticationError' || lower.includes('invalid username or password')) {
return {
title: t('login.mobile.err_bad_title', "That didn't work"),
+39 -2
View File
@@ -80,6 +80,7 @@ function signInLinkConfirmation(
*/
export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) {
const login = useAuthStore((state) => state.login);
const loginWithToken = useAuthStore((state) => state.loginWithToken);
const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail);
const loginViaPairing = useAuthStore((state) => state.loginViaPairing);
const clearError = useAuthStore((state) => state.clearError);
@@ -102,6 +103,10 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
// common no-2FA sign-in stays a two-field form.
const [totp, setTotp] = React.useState('');
const [totpRequired, setTotpRequired] = React.useState(false);
// Access-token (Bearer) sign-in in place of username and password, for
// providers that hand out API tokens (Fastmail accepts nothing else).
const [tokenMode, setTokenMode] = React.useState(false);
const [accessToken, setAccessToken] = React.useState('');
const [failedDomain, setFailedDomain] = React.useState<string | null>(null);
const [notice, setNotice] = React.useState<LoginErrorCopy | null>(null);
const [searching, setSearching] = React.useState(false);
@@ -261,6 +266,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setPassword(pending.password);
setServerUrl(pending.serverUrl);
setTotpRequired(true);
setTokenMode(false);
setBusy(null);
setNotice(describeLoginError(err, { serverUrl: target, t }));
setHistory((prev) => [...prev, step]);
@@ -340,6 +346,26 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setNotice({ title: t('login.mobile.notice_need_server', 'We need a server address first') });
return;
}
if (tokenMode) {
if (!accessToken.trim()) {
setNotice({ title: t('login.token_placeholder', 'Paste your API token') });
return;
}
setNotice(null);
setBusy('connecting');
const wasAuthenticated = useAuthStore.getState().isAuthenticated;
try {
await loginWithToken(target, accessToken, { addAccount: isAddMode });
finishIfSignedIn(wasAuthenticated);
} catch (err) {
setNotice(describeLoginError(err, { serverUrl: target, t }));
} finally {
setBusy(null);
}
return;
}
if (!email.trim() || !password) {
setNotice({ title: t('login.mobile.notice_need_credentials', 'Enter your email and password') });
return;
@@ -369,7 +395,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
} finally {
setBusy(null);
}
}, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl, totp, totpRequired, t]);
}, [accessToken, email, 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.
@@ -517,7 +543,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
<SigningInStep
phase={busy}
serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl}
email={pairing ? null : email.trim() || null}
email={pairing || tokenMode ? null : email.trim() || null}
/>
);
}
@@ -655,6 +681,17 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
}}
onSubmit={() => void handlePasswordSubmit()}
notice={notice}
tokenMode={tokenMode}
token={accessToken}
onChangeToken={(value) => {
setNotice(null);
setAccessToken(value);
}}
onToggleTokenMode={() => {
setNotice(null);
clearError();
setTokenMode((v) => !v);
}}
/>
) : null}
</LoginShell>
+98 -54
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, Pressable, StyleSheet, type TextInput } from 'react-native';
import { Eye, EyeOff } from '../../components/icons';
import { Eye, EyeOff, KeyRound } from '../../components/icons';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { Button, Input } from '../../components';
@@ -19,6 +19,11 @@ interface PasswordStepProps {
onChangeTotp?: (value: string) => void;
onSubmit: () => void;
notice?: { title: string; detail?: string } | null;
/** Access-token sign-in in place of username and password. */
tokenMode?: boolean;
token?: string;
onChangeToken?: (value: string) => void;
onToggleTokenMode?: () => void;
}
function hostOf(url: string): string {
@@ -38,6 +43,10 @@ export default function PasswordStep({
onChangeTotp,
onSubmit,
notice,
tokenMode = false,
token = '',
onChangeToken,
onToggleTokenMode,
}: PasswordStepProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -52,74 +61,107 @@ export default function PasswordStep({
<View style={styles.heading}>
<Text style={styles.title}>{t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })}</Text>
<Text style={styles.subtitle}>
{t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
{tokenMode
? t('login.token_hint', "Create an API token in your mail provider's settings and paste it here.")
: t('login.mobile.password_subtitle', 'Your password is sent to this server only, and stored in the device keychain.')}
</Text>
</View>
<Input
label={t('login.mobile.username_label', 'Email or username')}
placeholder={t('login.username_placeholder', 'user@example.com')}
value={email}
onChangeText={onChangeEmail}
testID="login-username-input"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
/>
<Input
label={t('login.password_label', 'Password')}
placeholder={t('login.password_placeholder', 'Enter your password')}
value={password}
onChangeText={onChangePassword}
testID="login-password-input"
autoFocus={Boolean(email)}
secureTextEntry={!showPassword}
autoComplete="current-password"
textContentType="password"
ref={passwordRef}
returnKeyType={totpRequired ? 'next' : 'go'}
blurOnSubmit={!totpRequired}
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
rightIcon={
<Pressable
onPress={() => setShowPassword((v) => !v)}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
>
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
</Pressable>
}
/>
{totpRequired ? (
{tokenMode ? (
<Input
label={t('login.totp_label', 'Two-factor code')}
placeholder="123456"
value={totp}
ref={totpRef}
onChangeText={(value) => onChangeTotp?.(value)}
label={t('login.token_label', 'Access token')}
placeholder={t('login.token_placeholder', 'Paste your API token')}
value={token}
onChangeText={(value) => onChangeToken?.(value)}
testID="login-token-input"
autoFocus
keyboardType="number-pad"
autoComplete="one-time-code"
textContentType="oneTimeCode"
maxLength={8}
secureTextEntry
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
spellCheck={false}
returnKeyType="go"
onSubmitEditing={onSubmit}
/>
) : null}
{tokenMode ? null : (
<>
<Input
label={t('login.mobile.username_label', 'Email or username')}
placeholder={t('login.username_placeholder', 'user@example.com')}
value={email}
onChangeText={onChangeEmail}
testID="login-username-input"
autoCapitalize="none"
autoCorrect={false}
autoComplete="email"
textContentType="username"
keyboardType="email-address"
returnKeyType="next"
blurOnSubmit={false}
onSubmitEditing={() => passwordRef.current?.focus()}
/>
<Input
label={t('login.password_label', 'Password')}
placeholder={t('login.password_placeholder', 'Enter your password')}
value={password}
onChangeText={onChangePassword}
testID="login-password-input"
autoFocus={Boolean(email)}
secureTextEntry={!showPassword}
autoComplete="current-password"
textContentType="password"
ref={passwordRef}
returnKeyType={totpRequired ? 'next' : 'go'}
blurOnSubmit={!totpRequired}
onSubmitEditing={totpRequired ? () => totpRef.current?.focus() : onSubmit}
rightIcon={
<Pressable
onPress={() => setShowPassword((v) => !v)}
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')}
>
{showPassword ? <EyeOff size={20} color={c.textMuted} /> : <Eye size={20} color={c.textMuted} />}
</Pressable>
}
/>
{totpRequired ? (
<Input
label={t('login.totp_label', 'Two-factor code')}
placeholder="123456"
value={totp}
ref={totpRef}
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}
<Button variant="default" size="md" onPress={onSubmit}>
{t('login.sign_in', 'Sign in')}
</Button>
{onToggleTokenMode ? (
<Pressable onPress={onToggleTokenMode} hitSlop={8} style={styles.toggle} accessibilityRole="button" testID="login-token-toggle">
<KeyRound size={16} color={c.textLink} />
<Text style={styles.link}>
{tokenMode ? t('login.password_toggle', 'Sign in with a password') : t('login.token_toggle', 'Sign in with an access token')}
</Text>
</Pressable>
) : null}
</View>
);
}
@@ -130,5 +172,7 @@ function makeStyles(c: ThemePalette) {
heading: { gap: spacing.sm },
title: { ...typography.h1, color: c.text },
subtitle: { ...typography.body, color: c.textSecondary },
toggle: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, paddingVertical: spacing.xs },
link: { ...typography.body, color: c.textLink },
});
}
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Access-token sign-in: the pasted token is cleaned up before it is sent, the
// account is named after the session, and a refused token leaves nothing
// registered.
const { InvalidTokenError, SESSION, connectWithToken } = vi.hoisted(() => {
class InvalidTokenError extends Error {
constructor() {
super('Invalid access token');
this.name = 'InvalidTokenError';
}
}
const SESSION = { apiUrl: 'https://api.fastmail.com/jmap/api/', username: 'ada@fastmail.com' };
const connectWithToken = vi.fn(async (_serverUrl: string, _token: string) => ({
session: SESSION,
username: 'ada@fastmail.com',
accountId: 'ada@fastmail.com@api.fastmail.com',
}));
return { InvalidTokenError, SESSION, connectWithToken };
});
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
connectWithToken: (serverUrl: string, token: string) => connectWithToken(serverUrl, token),
snapshot: vi.fn(() => ({ session: null, credentials: null, accountId: null })),
restoreSnapshot: vi.fn(),
onAuthFailure: vi.fn(() => () => undefined),
onTokenRefresh: vi.fn(() => () => undefined),
hasAccountCapability: vi.fn(() => false),
getAccountName: () => undefined,
getSharedMailAccounts: () => [],
request: vi.fn(async () => ({ methodResponses: [] })),
accountId: 'acc-1',
isConnected: true,
currentSession: SESSION,
username: 'ada@fastmail.com',
serverUrl: 'https://api.fastmail.com',
},
AuthenticationError: class AuthenticationError extends Error {},
NetworkError: class NetworkError extends Error {},
InvalidTokenError,
}));
vi.mock('../../lib/push-notifications', () => ({
teardownPushNotifications: vi.fn(async () => undefined),
teardownPushNotificationsForAccount: vi.fn(async () => undefined),
}));
import { useAuthStore } from '../auth-store';
import { useAccountStore } from '../account-store';
beforeEach(() => {
connectWithToken.mockClear();
useAccountStore.setState({ accounts: [], activeAccountId: null });
useAuthStore.setState({ isAuthenticated: false, error: null, activeAccountId: null });
});
describe('loginWithToken', () => {
it('sends the bare token and registers the account the session names', async () => {
await useAuthStore.getState().loginWithToken('https://api.fastmail.com/', ' Bearer fmu1-abc ');
expect(connectWithToken).toHaveBeenCalledWith('https://api.fastmail.com', 'fmu1-abc');
const account = useAccountStore.getState().getActiveAccount();
expect(account?.id).toBe('ada@fastmail.com@api.fastmail.com');
expect(account?.serverUrl).toBe('https://api.fastmail.com');
expect(useAuthStore.getState()).toMatchObject({
isAuthenticated: true,
username: 'ada@fastmail.com',
activeAccountId: 'ada@fastmail.com@api.fastmail.com',
});
});
it('refuses a token with spaces inside without asking the server', async () => {
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1 abc'))
.rejects.toThrow(InvalidTokenError);
expect(connectWithToken).not.toHaveBeenCalled();
expect(useAuthStore.getState().error).toBe('Invalid access token');
});
it('registers nothing when the server refuses the token', async () => {
connectWithToken.mockRejectedValueOnce(new InvalidTokenError());
await expect(useAuthStore.getState().loginWithToken('https://api.fastmail.com', 'fmu1-expired'))
.rejects.toThrow(InvalidTokenError);
expect(useAccountStore.getState().accounts).toHaveLength(0);
expect(useAuthStore.getState().isAuthenticated).toBe(false);
});
});
+35 -5
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { jmapClient, AuthenticationError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
import { jmapClient, AuthenticationError, InvalidTokenError, NetworkError, type ClientSnapshot } from '../api/jmap-client';
import type { JMAPSession } from '../api/types';
import { fetchAccountDisplayName, isStalwartSupported } from '../api/account-security';
import { useAccountStore } from './account-store';
@@ -86,6 +86,8 @@ export interface AuthState {
password: string,
opts?: { addAccount?: boolean; 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>;
/** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */
loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise<void>;
@@ -255,11 +257,24 @@ async function syncAccountDisplayName(accountId: string): Promise<void> {
// pairing both end here). Bootstraps a JMAP session from the token bundle,
// registers the account, and flips the store to connected. Throws on failure
// so the caller can surface a flow-specific error.
async function completeOAuthHandoff(
function completeOAuthHandoff(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
result: Extract<HandoffResult, { flow: 'oauth' }>,
opts?: { addAccount?: boolean },
): Promise<void> {
return completeBearerSignIn(set, get, result.serverUrl, () => jmapClient.connectWithOAuth(result.serverUrl, result.tokens), opts);
}
// Connects with `connect` (an OAuth bundle or a pasted access token, where the
// server names the account), registers the account, and flips the store to
// connected.
async function completeBearerSignIn(
set: (partial: Partial<AuthState>) => void,
get: () => AuthState,
serverUrl: string,
connect: () => Promise<{ session: JMAPSession; username: string; accountId: string }>,
opts?: { addAccount?: boolean },
): Promise<void> {
// Adding an account must not destroy the live one: the singleton keeps the
// previous connection until the new sign-in has actually succeeded, and a
@@ -268,7 +283,7 @@ async function completeOAuthHandoff(
let connected: { session: JMAPSession; username: string; accountId: string };
try {
connected = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens);
connected = await connect();
} catch (err) {
if (previous) jmapClient.restoreSnapshot(previous);
throw err;
@@ -279,7 +294,7 @@ async function completeOAuthHandoff(
const wasRegistered = !!accountStore.getAccountById(accountId);
try {
accountStore.addAccount({
serverUrl: result.serverUrl.replace(/\/+$/, ''),
serverUrl: serverUrl.replace(/\/+$/, ''),
username,
displayName: username,
email: username,
@@ -300,7 +315,7 @@ async function completeOAuthHandoff(
accountStore.setActiveAccount(accountId);
useEmailStore.getState().setActiveAccount(accountId);
applyConnectedState(set, session, result.serverUrl.replace(/\/+$/, ''), username, accountId);
applyConnectedState(set, session, serverUrl.replace(/\/+$/, ''), username, accountId);
// Start on the folder list now rather than once the mail screen has
// mounted; the screen joins this load.
void useEmailStore.getState().fetchMailboxes();
@@ -414,6 +429,21 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},
loginWithToken: async (serverUrl, typedToken, opts) => {
set({ isLoading: true, error: null });
// Pasted tokens often come with the scheme or surrounding whitespace.
const token = typedToken.trim().replace(/^Bearer\s+/i, '');
const base = serverUrl.replace(/\/+$/, '');
try {
if (!token || /\s/.test(token)) throw new InvalidTokenError();
await completeBearerSignIn(set, get, base, () => jmapClient.connectWithToken(base, token), opts);
set({ pendingTotpLogin: null });
} catch (err) {
set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' });
throw err;
}
},
loginViaWebmail: async (webmailUrl, opts) => {
set({ isLoading: true, error: null });
refuseAddWhenFull(set, opts);