From 96ef85159be0224b43bd2f4c0226cda979ed49c4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:07:36 +0100 Subject: [PATCH] fix: sign in with password and two-factor code on Stalwart 1.0 --- locales/rn/en.json | 2 + src/lib/__tests__/totp-login.test.ts | 175 +++++++++++ src/lib/login-errors.ts | 9 + src/lib/totp-login.ts | 417 +++++++++++++++++++++++---- 4 files changed, 544 insertions(+), 59 deletions(-) create mode 100644 src/lib/__tests__/totp-login.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index f81aa537..414c3c27 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -553,6 +553,8 @@ "err_bad_title": "That didn't work", "err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.", "err_cert_title": "Couldn't verify {host}", + "err_client_rejected_detail": "The server refused Bulwark as a sign-in client. Ask your administrator to allow it, or sign in with an app password.", + "err_client_rejected_title": "{host} didn't accept the app", "err_code_expired_detail": "Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.", "err_code_expired_or_used_detail": "Show a new one in the webmail and scan again.", "err_code_expired_or_used_title": "That code has expired or was already used", diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts new file mode 100644 index 00000000..54f39d6c --- /dev/null +++ b/src/lib/__tests__/totp-login.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../client-cert', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, secureFetch: vi.fn() }; +}); + +import { secureFetch } from '../client-cert'; +import { + exchangePasswordForTokens, + resetLoginClients, + resolveLoginClient, + TotpLoginError, + REGISTERED_REDIRECT_URI, +} from '../totp-login'; +import { describeLoginError } from '../login-errors'; + +const fetchMock = secureFetch as unknown as ReturnType; +const BASE = 'https://mail.example.com'; + +function json(body: unknown, status = 200, extra?: { url?: string; redirected?: boolean }): Response { + const res = new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + if (extra) { + Object.defineProperty(res, 'url', { value: extra.url }); + Object.defineProperty(res, 'redirected', { value: extra.redirected }); + } + return res; +} + +interface Call { url: string; method: string; body?: string } + +// A fake Stalwart of either line. `v1` knows no client but the ones it +// registered (ids `swc1.`), takes `{query, consent}` and answers with a +// location; 0.16 has no client check and answers `client_code`. +function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) => unknown) { + const calls: Call[] = []; + let registered = 0; + fetchMock.mockImplementation(async (url: string, init?: RequestInit) => { + const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined }; + calls.push(call); + const path = url.slice(BASE.length).split('?')[0]; + if (path === '/api/auth/client') { + if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404); + const clientId = new URL(url).searchParams.get('client_id') ?? ''; + return json(clientId.startsWith('swc1.') ? { name: 'Bulwark Mobile' } : { error: 'invalid_client', description: 'Unknown client id', redirect: false }); + } + if (path === '/auth/register') { + if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400); + registered += 1; + return json({ client_id: `swc1.${registered}` }, 201); + } + if (path === '/api/auth') { + const body = JSON.parse(call.body!); + if (version === '0.16') return json(answer ? answer(new URLSearchParams()) : { type: 'authenticated', client_code: 'code-016' }); + if (typeof body.query !== 'string') return json({ title: 'Invalid parameters', detail: 'JSON deserialization failed' }, 400); + const query = new URLSearchParams(body.query); + if (answer) return json(answer(query)); + if (!query.get('client_id')?.startsWith('swc1.')) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' }); + return json({ + type: 'authenticated', + location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`, + }); + } + if (path === '/auth/token') { + return json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }); + } + return json({}, 404); + }); + return calls; +} + +const tokenCall = (calls: Call[]) => new URLSearchParams(calls.find((c) => c.url.endsWith('/auth/token'))!.body); +const authCalls = (calls: Call[]) => calls.filter((c) => c.url === `${BASE}/api/auth`); + +describe('exchangePasswordForTokens', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetLoginClients(); + }); + + it('signs in on Stalwart 0.16 with the webmail client id and the 0.16 fields', async () => { + const calls = fakeServer('0.16'); + const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); + + expect(tokens.clientId).toBe('bulwark-webmail'); + const body = JSON.parse(authCalls(calls)[0].body!); + expect(body).toMatchObject({ clientId: 'bulwark-webmail', redirectUri: 'bulwarkmobile://auth/callback', mfaToken: '123456', consent: true }); + expect(tokenCall(calls).get('code')).toBe('code-016'); + expect(tokenCall(calls).get('client_id')).toBe('bulwark-webmail'); + expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); + }); + + 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'); + + expect(tokens.clientId).toBe('swc1.1'); + expect(tokens.source).toBe('totp'); + const register = JSON.parse(calls.find((c) => c.url.endsWith('/auth/register'))!.body!); + expect(register.redirect_uris).toEqual([REGISTERED_REDIRECT_URI]); + expect(register.token_endpoint_auth_method).toBe('none'); + expect(register.scope).toContain('urn:ietf:params:oauth:scope:mail'); + // The password goes out once, with the registered client. + expect(authCalls(calls)).toHaveLength(1); + const query = new URLSearchParams(JSON.parse(authCalls(calls)[0].body!).query); + expect(query.get('client_id')).toBe('swc1.1'); + expect(query.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); + expect(tokenCall(calls).get('code')).toBe('code-10'); + expect(tokenCall(calls).get('client_id')).toBe('swc1.1'); + expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); + }); + + it('keeps the registered client per server for the next sign-in', async () => { + fakeServer('1.0'); + await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); + const calls = fakeServer('1.0'); + const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '654321'); + + expect(tokens.clientId).toBe('swc1.1'); + expect(calls.map((c) => c.url.slice(BASE.length).split('?')[0])).toEqual(['/api/auth', '/auth/token']); + }); + + it('refuses a code whose state is not the one it sent', async () => { + fakeServer('1.0', (q) => ({ type: 'authenticated', location: `${q.get('redirect_uri')}?code=c&state=other` })); + const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e); + + expect(err).toBeInstanceOf(TotpLoginError); + expect(err.code).toBe('login_failed'); + expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/auth/token'))).toBe(false); + }); + + it('reports a refused authorization request as the server refusing the app, not the password', async () => { + fakeServer('1.0', (q) => ({ + type: 'redirect', + location: `${q.get('redirect_uri')}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed`, + })); + const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e); + + expect(err.code).toBe('client_rejected'); + expect(describeLoginError(err, { serverUrl: BASE }).title).toBe("mail.example.com didn't accept the app"); + }); + + it('maps the credential verdicts of 1.0', async () => { + fakeServer('1.0', () => ({ type: 'failure' })); + expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('invalid_credentials'); + fakeServer('1.0', () => ({ type: 'mfaRequired' })); + expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('totp_required'); + }); + + it('does not post the password when the server redirects to another host', async () => { + const calls: string[] = []; + fetchMock.mockImplementation(async (url: string) => { + calls.push(url); + return json({}, 404, { url: 'https://evil.example/api/auth/client', redirected: true }); + }); + const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e); + + expect(err).toBeInstanceOf(TotpLoginError); + expect(calls.some((u) => u.endsWith('/api/auth'))).toBe(false); + }); +}); + +describe('resolveLoginClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetLoginClients(); + }); + + it('keeps the webmail client when the server is unreachable, without remembering it', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Network request failed')); + expect((await resolveLoginClient(BASE)).clientId).toBe('bulwark-webmail'); + fakeServer('1.0'); + expect((await resolveLoginClient(BASE)).clientId).toBe('swc1.1'); + }); +}); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index c246dd72..a1b18470 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -126,6 +126,15 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} }; } + // The server refused the app itself (Stalwart 1.0 with app registration + // turned off, or a client the admin restricted), not the password. + if (name === 'TotpLoginError' && (err as { code?: string }).code === 'client_rejected') { + return { + title: t('login.mobile.err_client_rejected_title', "{host} didn't accept the app", { host }), + detail: t('login.mobile.err_client_rejected_detail', 'The server refused Bulwark as a sign-in client. Ask your administrator to allow it, or sign in with an app password.'), + }; + } + if (name === 'TotpLoginError' && lower.includes('invalid')) { return { title: t('login.mobile.err_bad_title', "That didn't work"), diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index b0cdb020..5e596a72 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -7,22 +7,57 @@ // `POST {server}/api/auth`, which answers with a short-lived authorization // code that `POST {server}/auth/token` exchanges (with PKCE) for OAuth // tokens. The webmail drives the same two steps server-side in -// app/api/auth/totp-token-exchange/route.ts; a native client has no CORS +// lib/auth/stalwart-password-login.ts; a native client has no CORS // constraint and talks to the mail server directly. // +// The two server lines speak different dialects (webmail +// lib/auth/stalwart-login-request.ts): +// - 0.16 takes the authorization parameters as fields (`clientId`, +// `redirectUri`, `codeChallenge`, ...), accepts any client id and answers +// `{ type: "authenticated", client_code }`. +// - 1.0 takes the authorization request as a query string (`query`), wants +// `consent: true`, knows only registered clients and answers +// `{ type: "authenticated", location }`, the redirect URI carrying the code. +// A body without `query` gets a 400 "JSON deserialization failed". +// Neither rejects fields it doesn't know, so one body serves both. +// // Token auth also survives TOTP rotation, unlike Basic auth, which would have // to embed the ~30 s code in every request. -import { secureFetch } from './client-cert'; +import { mayFollowRedirect, secureFetch } from './client-cert'; import { sha256Hex } from './sha256'; -import { base64UrlEncode, randomBytes } from './random'; +import { base64UrlEncode, randomBytes, randomHex } from './random'; import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; import { NetworkError } from '../api/jmap-client'; -// Stalwart's built-in OAuth accepts the webmail's public client id; using the -// same one keeps refresh behaviour identical to the browser-handoff bundle. +// 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. export const DEFAULT_CLIENT_ID = 'bulwark-webmail'; +/** + * Redirect URI of the client the app registers at a Stalwart 1.0 that knows + * no client for it. 1.0 lets a public client register only a loopback URI or + * a private-use scheme with a dot in it (RFC 8252), which `bulwarkmobile://` + * is not; this one is the iOS bundle id. The password login never visits it + * (/api/auth hands the location back); the browser sign-in comes back on it, + * so the app registers the scheme too. + */ +export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback'; + +/** + * The data scopes Stalwart 1.0 grants a client that isn't first-party. A + * token reaches only what these name (no scope: nothing but the session); + * 0.16 ignores them. + */ +export const STALWART_DATA_SCOPES = [ + 'urn:ietf:params:oauth:scope:mail', + 'urn:ietf:params:oauth:scope:contacts', + 'urn:ietf:params:oauth:scope:calendars', + 'urn:ietf:params:oauth:scope:files', +]; + +export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES].join(' '); + export class LoginEndpointMissingError extends Error { constructor() { super('Structured login endpoint not available'); @@ -31,7 +66,10 @@ export class LoginEndpointMissingError extends Error { } export class TotpLoginError extends Error { - code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed'; + // `client_rejected`: the server refused the app as an OAuth client (1.0: + // registration refused or the authorization request itself rejected), + // before it looked at the credentials. + code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed' | 'client_rejected'; constructor(code: TotpLoginError['code'], message?: string) { super(message ?? code); this.name = 'TotpLoginError'; @@ -55,18 +93,294 @@ export function generateCodeChallenge(verifier: string): string { return base64UrlEncode(hexToBytes(digest)); } -interface LoginResult { - type?: string; - // The response keeps snake_case: only the LoginResponse variant *tags* are - // camelCased server-side, not the struct fields (the request fields are). - client_code?: string; +// ── Which client the app signs in as ─────────────────────── + +export interface LoginClient { + clientId: string; + redirectUri: string; +} + +const CLIENT_CHECK_TIMEOUT_MS = 8_000; +const CLIENT_CACHE_MAX = 32; +// Per server. 1.0's registered client ids are stateless (sealed with the +// server's key): the same metadata registers to the same id again, so a lost +// cache costs a round trip, never a sign-in. The token bundle keeps the id it +// was issued to, which is what refresh and revocation present. +const loginClients = new Map(); + +function trimBase(url: string): string { + return url.replace(/\/+$/, ''); +} + +/** Test hook. */ +export function resetLoginClients(): void { + loginClients.clear(); +} + +function rememberClient(base: string, client: LoginClient): void { + if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) { + const oldest = loginClients.keys().next().value; + if (oldest !== undefined) loginClients.delete(oldest); + } + 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 platform fetch follows redirects itself, so the check is on where +// the answer came from; the client check below runs first and is a plain GET, +// which keeps the password from being posted anywhere the server redirects. +function assertSameServer(requested: string, response: Response): void { + if (response.redirected && response.url && !mayFollowRedirect(requested, response.url)) { + throw new TotpLoginError('login_failed', `${requested} redirects to another host`); + } +} + +/** RFC 7591 registration as a public client, which 1.0 allows without credentials. */ +async function registerPublicClient(base: string): Promise { + const url = `${base}/auth/register`; + const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); + let response: Response; + try { + response = await secureFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ + client_name: 'Bulwark Mobile', + redirect_uris: [REGISTERED_REDIRECT_URI], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: STALWART_LOGIN_SCOPE, + }), + signal: t.signal, + timeoutMs: CLIENT_CHECK_TIMEOUT_MS, + }); + } catch (err) { + throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); + } finally { + t.done(); + } + assertSameServer(url, response); + const body = (await response.json().catch(() => null)) as { client_id?: unknown; error?: unknown } | null; + if (!response.ok || typeof body?.client_id !== 'string' || !body.client_id) { + const reason = typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`; + throw new TotpLoginError('client_rejected', `The server refused to register the app (${reason})`); + } + const client = { clientId: body.client_id, redirectUri: REGISTERED_REDIRECT_URI }; + rememberClient(base, client); + return client; +} + +/** + * The OAuth client to sign in to `serverUrl` as. Stalwart 1.0's client check + * (GET /api/auth/client, which 0.16 doesn't have) says whether it knows the + * webmail's client id with the app's redirect URI; when it doesn't, the app + * registers its own public client. Any other server, and 0.16, gets + * `bulwark-webmail` as before. Unreachable: the default, uncached, and the + * request that needs it fails on its own. + */ +export async function resolveLoginClient(serverUrl: string): Promise { + 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 query = new URLSearchParams({ + response_type: 'code', + client_id: DEFAULT_CLIENT_ID, + redirect_uri: HANDOFF_REDIRECT_URI, + state: 'probe', + code_challenge: 'A'.repeat(43), + code_challenge_method: 'S256', + }); + const url = `${base}/api/auth/client?${query.toString()}`; + const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); + let response: Response; + try { + response = await secureFetch(url, { + headers: { Accept: 'application/json' }, + signal: t.signal, + timeoutMs: CLIENT_CHECK_TIMEOUT_MS, + }); + } catch { + return fallback; + } finally { + t.done(); + } + if (!response || typeof response.status !== 'number') return fallback; + assertSameServer(url, response); + // 0.16 and servers that aren't Stalwart: no such endpoint. + if (!response.ok) { + rememberClient(base, fallback); + return fallback; + } + const body = (await response.json().catch(() => null)) as { error?: unknown } | null; + if (!body || typeof body !== 'object') { + rememberClient(base, fallback); + return fallback; + } + // `invalid_client` (unknown id) or a redirect URI the admin's client + // doesn't list: either way the webmail's client isn't the app's to use. + if (typeof body.error === 'string' && body.error) return registerPublicClient(base); + rememberClient(base, fallback); + return fallback; +} + +// ── /api/auth request and answer ─────────────────────────── + +export interface StalwartLoginRequest { + username: string; + password: string; + totp?: string; + clientId: string; + redirectUri: string; + /** S256 PKCE challenge. */ + codeChallenge: string; + /** Echoed back in 1.0's redirect location. */ + state: string; +} + +export function stalwartLoginBody(req: StalwartLoginRequest): Record { + const query = new URLSearchParams({ + response_type: 'code', + client_id: req.clientId, + redirect_uri: req.redirectUri, + state: req.state, + scope: STALWART_LOGIN_SCOPE, + code_challenge: req.codeChallenge, + code_challenge_method: 'S256', + }); + return { + type: 'authCode', + accountName: req.username, + accountSecret: req.password, + ...(req.totp ? { mfaToken: req.totp } : {}), + // Stalwart 0.16 + clientId: req.clientId, + redirectUri: req.redirectUri, + codeChallenge: req.codeChallenge, + codeChallengeMethod: 'S256', + // Stalwart 1.0. The user typed the password into the app's own sign-in + // form, which is the consent the server would otherwise ask for. + query: query.toString(), + consent: true, + }; +} + +export type StalwartLoginOutcome = + | { kind: 'code'; code: string } + | { kind: 'mfa_required' } + | { kind: 'invalid_credentials' } + /** + * The server refused the authorization request itself (1.0: unknown + * client, redirect URI not registered, scope not allowed), before looking + * at the credentials. + */ + | { kind: 'rejected'; error: string; description?: string }; + +function locationParams(location: string): URLSearchParams | null { + // A private-use scheme isn't something every URL parser takes apart, so + // read the query by hand. + const q = location.indexOf('?'); + if (q < 0) return null; + const h = location.indexOf('#', q); + return new URLSearchParams(location.slice(q + 1, h >= 0 ? h : undefined)); +} + +/** Reads the JSON answer of POST /api/auth (HTTP 200) of either server line. */ +export function readStalwartLoginResponse(body: unknown, state: string): StalwartLoginOutcome { + const res = (body && typeof body === 'object' ? body : {}) as Record; + switch (res.type) { + case 'authenticated': { + // The response keeps snake_case: only the LoginResponse variant tags + // are camelCased server-side, not the struct fields. + if (typeof res.client_code === 'string' && res.client_code) { + return { kind: 'code', code: res.client_code }; + } + const params = typeof res.location === 'string' ? locationParams(res.location) : null; + const code = params?.get('code'); + if (!code) return { kind: 'rejected', error: 'missing_code' }; + if (params?.get('state') !== state) return { kind: 'rejected', error: 'state_mismatch' }; + return { kind: 'code', code }; + } + case 'mfaRequired': + return { kind: 'mfa_required' }; + case 'invalid': + return { + kind: 'rejected', + error: typeof res.error === 'string' ? res.error : 'invalid_request', + ...(typeof res.description === 'string' ? { description: res.description } : {}), + }; + case 'redirect': { + // 1.0 sends errors it may show the client to the redirect URI. + const params = typeof res.location === 'string' ? locationParams(res.location) : null; + const description = params?.get('error_description'); + return { + kind: 'rejected', + error: params?.get('error') || 'access_denied', + ...(description ? { description } : {}), + }; + } + default: + return { kind: 'invalid_credentials' }; + } +} + +async function requestLoginCode( + base: string, + username: string, + password: string, + totp: string | undefined, + client: LoginClient, + challenge: string, +): Promise { + const state = randomHex(16); + const url = `${base}/api/auth`; + let loginResponse: Response; + try { + loginResponse = await secureFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(stalwartLoginBody({ + username, + password, + totp, + clientId: client.clientId, + redirectUri: client.redirectUri, + codeChallenge: challenge, + state, + })), + }); + } catch (err) { + throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); + } + assertSameServer(url, loginResponse); + + if (loginResponse.status === 404) throw new LoginEndpointMissingError(); + if (!loginResponse.ok) { + throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`); + } + let body: unknown; + try { + body = await loginResponse.json(); + } catch { + throw new TotpLoginError('login_failed', 'Login response was not JSON'); + } + return readStalwartLoginResponse(body, state); } /** * Exchange username + password (+ TOTP) for an OAuth token bundle. Throws * `LoginEndpointMissingError` when the server predates the structured * endpoint (404) so the caller can fall back to legacy Basic auth, - * `TotpLoginError` for rejected credentials, `NetworkError` when unreachable. + * `TotpLoginError` for rejected credentials or a refused client, + * `NetworkError` when unreachable. */ export async function exchangePasswordForTokens( serverUrl: string, @@ -75,68 +389,52 @@ export async function exchangePasswordForTokens( totp?: string, opts?: { clientId?: string; redirectUri?: string }, ): Promise { - const base = serverUrl.replace(/\/+$/, ''); - const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID; - const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI; + const base = trimBase(serverUrl); const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); - let loginResponse: Response; - try { - loginResponse = await secureFetch(`${base}/api/auth`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ - type: 'authCode', - accountName: username, - accountSecret: password, - ...(totp ? { mfaToken: totp } : {}), - clientId, - redirectUri, - codeChallenge: challenge, - codeChallengeMethod: 'S256', - }), - }); - } catch (err) { - throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); + 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); + // A remembered client the server no longer knows (another server now + // answers at the address, or its key changed): 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.has(base)) { + loginClients.delete(base); + const fresh = await resolveLoginClient(base); + if (fresh.clientId !== client.clientId) { + client = fresh; + outcome = await requestLoginCode(base, username, password, totp, client, challenge); + } } - if (loginResponse.status === 404) throw new LoginEndpointMissingError(); - if (!loginResponse.ok) { - throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`); - } - - let login: LoginResult; - try { - login = (await loginResponse.json()) as LoginResult; - } catch { - throw new TotpLoginError('login_failed', 'Login response was not JSON'); - } - - switch (login.type) { - case 'authenticated': + switch (outcome.kind) { + case 'code': break; - case 'mfaRequired': + case 'mfa_required': throw new TotpLoginError('totp_required', 'TOTP_REQUIRED'); - case 'failure': - default: + case 'invalid_credentials': throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code'); - } - if (!login.client_code) { - throw new TotpLoginError('login_failed', 'Login response missing authorization code'); + case 'rejected': + throw new TotpLoginError( + outcome.error === 'missing_code' || outcome.error === 'state_mismatch' ? 'login_failed' : 'client_rejected', + `Sign-in refused: ${[outcome.error, outcome.description].filter(Boolean).join(': ')}`, + ); } const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', - code: login.client_code, - client_id: clientId, - redirect_uri: redirectUri, + code: outcome.code, + client_id: client.clientId, + redirect_uri: client.redirectUri, code_verifier: verifier, }); + const tokenUrl = `${base}/auth/token`; let tokenResponse: Response; try { - tokenResponse = await secureFetch(`${base}/auth/token`, { + tokenResponse = await secureFetch(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: tokenParams.toString(), @@ -144,6 +442,7 @@ export async function exchangePasswordForTokens( } catch (err) { throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } + assertSameServer(tokenUrl, tokenResponse); if (!tokenResponse.ok) { throw new TotpLoginError('token_exchange_failed', `Token exchange failed: ${tokenResponse.status}`); } @@ -160,8 +459,8 @@ export async function exchangePasswordForTokens( accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined, - tokenEndpoint: `${base}/auth/token`, - clientId, + tokenEndpoint: tokenUrl, + clientId: client.clientId, source: 'totp', }; }