From b4331a9bf98e338747c3ee780c3a28fb32a9ed15 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:14:30 +0100 Subject: [PATCH] feat: sign in with an access token --- src/api/__tests__/jmap-client.test.ts | 70 +++++++- src/api/jmap-client.ts | 63 +++++++- src/components/settings/AccountSettings.tsx | 8 +- src/lib/__tests__/login-errors.test.ts | 8 + src/lib/login-errors.ts | 7 + src/screens/LoginScreen.tsx | 41 ++++- src/screens/login/PasswordStep.tsx | 152 +++++++++++------- .../__tests__/auth-store-token-login.test.ts | 89 ++++++++++ src/stores/auth-store.ts | 40 ++++- 9 files changed, 407 insertions(+), 71 deletions(-) create mode 100644 src/stores/__tests__/auth-store-token-login.test.ts diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index b67b8c3a..55d42b01 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -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', () => { diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 7752e3e4..8cf47701 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -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 { + /** + * 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() { diff --git a/src/components/settings/AccountSettings.tsx b/src/components/settings/AccountSettings.tsx index eac866a0..c6cd5f9f 100644 --- a/src/components/settings/AccountSettings.tsx +++ b/src/components/settings/AccountSettings.tsx @@ -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(null); useEffect(() => { @@ -128,7 +130,9 @@ export function AccountSettings() { {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')} diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index 02aca1e2..0059d62d 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -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.'); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index 9ee7eaef..c246dd72 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -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"), diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 49cb67b1..edc927aa 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -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(null); const [notice, setNotice] = React.useState(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 ); } @@ -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} diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx index 2219ec12..3bd6b8fe 100644 --- a/src/screens/login/PasswordStep.tsx +++ b/src/screens/login/PasswordStep.tsx @@ -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({ {t('login.mobile.password_title', 'Sign in to {host}', { host: hostOf(serverUrl) })} - {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.')} - passwordRef.current?.focus()} - /> - - totpRef.current?.focus() : onSubmit} - rightIcon={ - setShowPassword((v) => !v)} - hitSlop={14} - accessibilityRole="button" - accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')} - > - {showPassword ? : } - - } - /> - - {totpRequired ? ( + {tokenMode ? ( 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 : ( + <> + passwordRef.current?.focus()} + /> + + totpRef.current?.focus() : onSubmit} + rightIcon={ + setShowPassword((v) => !v)} + hitSlop={14} + accessibilityRole="button" + accessibilityLabel={showPassword ? t('login.hide_password', 'Hide password') : t('login.show_password', 'Show password')} + > + {showPassword ? : } + + } + /> + + {totpRequired ? ( + onChangeTotp?.(value)} + autoFocus + keyboardType="number-pad" + autoComplete="one-time-code" + textContentType="oneTimeCode" + maxLength={8} + returnKeyType="go" + onSubmitEditing={onSubmit} + /> + ) : null} + + )} + {notice ? : null} + + {onToggleTokenMode ? ( + + + + {tokenMode ? t('login.password_toggle', 'Sign in with a password') : t('login.token_toggle', 'Sign in with an access token')} + + + ) : null} ); } @@ -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 }, }); } diff --git a/src/stores/__tests__/auth-store-token-login.test.ts b/src/stores/__tests__/auth-store-token-login.test.ts new file mode 100644 index 00000000..6ecf76c7 --- /dev/null +++ b/src/stores/__tests__/auth-store-token-login.test.ts @@ -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); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 15d60770..48e491cd 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -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; + /** 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; loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; /** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */ loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise; @@ -255,11 +257,24 @@ async function syncAccountDisplayName(accountId: string): Promise { // 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) => void, get: () => AuthState, result: Extract, opts?: { addAccount?: boolean }, +): Promise { + 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) => void, + get: () => AuthState, + serverUrl: string, + connect: () => Promise<{ session: JMAPSession; username: string; accountId: string }>, + opts?: { addAccount?: boolean }, ): Promise { // 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((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);