fix: sign in with password and two-factor code on Stalwart 1.0

This commit is contained in:
Linus Rath
2026-10-29 02:07:36 +01:00
parent 24232bf2e9
commit 96ef85159b
4 changed files with 544 additions and 59 deletions
+2
View File
@@ -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",
+175
View File
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../client-cert', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client-cert')>();
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<typeof vi.fn>;
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.<n>`), 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');
});
});
+9
View File
@@ -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"),
+358 -59
View File
@@ -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<string, LoginClient>();
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<LoginClient> {
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<LoginClient> {
const base = trimBase(serverUrl);
const cached = loginClients.get(base);
if (cached) return cached;
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
const 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<string, unknown> {
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<string, unknown>;
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<StalwartLoginOutcome> {
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<OAuthTokens> {
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',
};
}