From 8860dde005653dbf8b71892cfc66dc822a94876c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:48:35 +0100 Subject: [PATCH] fix: sign in with password and TOTP on Stalwart 1.0 --- app/api/auth/token/route.ts | 5 +- app/api/auth/totp-token-exchange/route.ts | 8 +- lib/__tests__/oauth-token-route.test.ts | 25 ++ .../stalwart-password-login-v1.test.ts | 190 +++++++++++++ lib/auth/pair-grant.ts | 14 +- lib/auth/stalwart-login-request.ts | 124 +++++++++ lib/auth/stalwart-password-login.ts | 262 +++++++++++++++--- lib/oauth/token-exchange.ts | 20 +- 8 files changed, 588 insertions(+), 60 deletions(-) create mode 100644 lib/__tests__/stalwart-password-login-v1.test.ts create mode 100644 lib/auth/stalwart-login-request.ts diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 0631a5399..05e7ec29c 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -14,6 +14,7 @@ import { getMetadata, getRequiredConfig, getTokenEndpoint, + defaultClientOptions, DEFAULT_CLIENT_ID, } from '@/lib/oauth/token-exchange'; import type { OAuthMetadata } from '@/lib/oauth/discovery'; @@ -50,7 +51,7 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada const params = buildOAuthParams({ token, token_type_hint: 'refresh_token', - }, serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + }, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID }))); try { // Never follow a redirect with the refresh token and client secret in @@ -215,7 +216,7 @@ export async function PUT(request: NextRequest) { const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken, - }, serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + }, serverId, await defaultClientOptions(serverId)); const tokenResponse = await fetch(tokenEndpoint, { method: 'POST', diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 5ce25a817..6172b7283 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -46,10 +46,10 @@ async function attemptLogin( // server entry has its own oauth block configured. const serverList = parseJmapServers(configManager.get('jmapServers', [])); const entry = findServerById(serverList, serverId); - const clientId = entry?.oauth?.clientId + const configuredClientId = entry?.oauth?.clientId || configManager.get('oauthClientId', '') - || process.env.OAUTH_CLIENT_ID - || DEFAULT_CLIENT_ID; + || process.env.OAUTH_CLIENT_ID; + const clientId = configuredClientId || DEFAULT_CLIENT_ID; const clientSecret = entry?.oauth?.clientSecret || configManager.get('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET @@ -75,6 +75,8 @@ async function attemptLogin( redirectUri, trusted: upstreamTrusted, clientIp, + // Stalwart 1.0 knows no client by the default name. + registerUnknownClient: !configuredClientId, }); // Only a rejected password spends the budget. `totp_required` means the // password was right and the code is still missing, which every TOTP login diff --git a/lib/__tests__/oauth-token-route.test.ts b/lib/__tests__/oauth-token-route.test.ts index d220afc01..dc421c2bd 100644 --- a/lib/__tests__/oauth-token-route.test.ts +++ b/lib/__tests__/oauth-token-route.test.ts @@ -24,12 +24,14 @@ const buildOAuthParams = vi.fn(); const getMetadata = vi.fn(); const getRequiredConfig = vi.fn(); const exchangeCodeForTokens = vi.fn(); +const defaultClientOptions = vi.fn(async (_serverId?: unknown) => ({ fallbackClientId: 'bulwark-webmail' })); vi.mock('@/lib/oauth/token-exchange', () => ({ exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args), buildOAuthParams: (...args: unknown[]) => buildOAuthParams(...args), getMetadata: (...args: unknown[]) => getMetadata(...args), getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args), getTokenEndpoint: (...args: unknown[]) => getTokenEndpoint(...args), + defaultClientOptions: (serverId: unknown) => defaultClientOptions(serverId), DEFAULT_CLIENT_ID: 'bulwark-webmail', })); @@ -281,6 +283,29 @@ describe('oauth token route - access token cache (#552)', () => { ); }); + it('refreshes with the client the TOTP login registered on Stalwart 1.0', async () => { + // 1.0 knows no client named bulwark-webmail; the login signed in with a + // client it registered, and a refresh naming another client is refused. + defaultClientOptions.mockResolvedValueOnce({ fallbackClientId: 'swc1.registered' }); + cookieStore.set('jmap_rt', 'totp-minted-refresh-token'); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ access_token: 'fresh-token', expires_in: 1800, refresh_token: 'rotated' }), + }); + + const { status } = await callPut({ force: 'true' }); + + expect(status).toBe(200); + expect(defaultClientOptions).toHaveBeenCalledWith(null); + expect(buildOAuthParams).toHaveBeenCalledWith( + { grant_type: 'refresh_token', refresh_token: 'totp-minted-refresh-token' }, + null, + { fallbackClientId: 'swc1.registered' }, + ); + // 1.0 rotates refresh tokens; the old one is dead once the new one is used. + expect(cookieStore.get('jmap_rt')?.value).toBe('rotated'); + }); + it('does not serve a cached token once the refresh token is gone', async () => { seedCachedToken('orphan-token', 1500); diff --git a/lib/__tests__/stalwart-password-login-v1.test.ts b/lib/__tests__/stalwart-password-login-v1.test.ts new file mode 100644 index 000000000..ccc96ed4a --- /dev/null +++ b/lib/__tests__/stalwart-password-login-v1.test.ts @@ -0,0 +1,190 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/logger', () => ({ + logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} }, +})); + +import { + STALWART_LOOPBACK_REDIRECT_URI, + resetStalwartDefaultClients, + stalwartDefaultClientId, + stalwartPasswordLogin, +} from '@/lib/auth/stalwart-password-login'; + +// Stalwart 1.0 changed its structured login: the authorization request goes in +// as a query string (`query`), the answer carries the redirect location with +// the code instead of `client_code`, and only clients it knows may sign in. +// The answers below are the ones both servers gave live (0.16.27, 1.0.0). + +const SERVER = 'https://mail.example.org'; +const REDIRECT = 'https://webmail.example/en/auth/callback'; + +interface Call { url: string; method: string; body: string } +let calls: Call[]; + +type Handler = (call: Call) => Response; + +function stub(handler: Handler) { + vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => { + const call = { url: String(input), method: init?.method ?? 'GET', body: String(init?.body ?? '') }; + calls.push(call); + return handler(call); + })); +} + +/** Stalwart 1.0's /api/auth: knows `known` clients, answers with the redirect location. */ +function v1(known: string[], registered = 'swc1.registered'): Handler { + return (call) => { + if (call.url === `${SERVER}/api/auth`) { + const body = JSON.parse(call.body); + if (typeof body.query !== 'string') { + return Response.json({ type: 'about:blank', status: 400, title: 'Invalid parameters', detail: 'JSON deserialization failed' }, { status: 400 }); + } + const q = new URLSearchParams(body.query); + if (!known.includes(q.get('client_id') ?? '')) { + return Response.json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' }); + } + if (body.consent !== true) { + return Response.json({ type: 'redirect', location: `${q.get('redirect_uri')}?error=access_denied&state=${q.get('state')}` }); + } + return Response.json({ + type: 'authenticated', + location: `${q.get('redirect_uri')}?code=v1-code&state=${encodeURIComponent(q.get('state') ?? '')}&iss=${encodeURIComponent(SERVER)}`, + }); + } + if (call.url === `${SERVER}/auth/register`) { + return Response.json({ client_id: registered, redirect_uris: JSON.parse(call.body).redirect_uris }, { status: 201 }); + } + if (call.url.startsWith(`${SERVER}/api/auth/client?`)) { + const q = new URL(call.url).searchParams; + return known.includes(q.get('client_id') ?? '') + ? Response.json({ name: null, scopes: [] }) + : Response.json({ error: 'invalid_client', description: 'Unknown client id', redirect: false }); + } + if (call.url === `${SERVER}/auth/token`) { + const p = new URLSearchParams(call.body); + return p.get('code') === 'v1-code' + ? Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }) + : Response.json({ error: 'invalid_grant' }, { status: 400 }); + } + return new Response('not found', { status: 404 }); + }; +} + +/** Stalwart 0.16's /api/auth: any client id, answers with client_code. */ +const v016: Handler = (call) => { + if (call.url === `${SERVER}/api/auth`) return Response.json({ type: 'authenticated', client_code: 'old-code', iss: SERVER }); + if (call.url === `${SERVER}/auth/token`) return Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }); + return Response.json({ type: 'about:blank', status: 404, title: 'Not Found' }, { status: 404 }); +}; + +function login(clientId: string, registerUnknownClient = false) { + return stalwartPasswordLogin({ + serverUrl: SERVER, + username: 'alice@example.org', + password: 'hunter2', + totp: '123456', + clientId, + redirectUri: REDIRECT, + trusted: true, + registerUnknownClient, + }); +} + +const tokenParams = () => new URLSearchParams(calls.find((c) => c.url === `${SERVER}/auth/token`)!.body); + +beforeEach(() => { + calls = []; + resetStalwartDefaultClients(); +}); + +describe('stalwartPasswordLogin on Stalwart 1.0', () => { + it('signs in with a client the server knows, reading the code from the location', async () => { + stub(v1(['webmail'])); + const result = await login('webmail'); + + expect(result).toMatchObject({ ok: true, clientId: 'webmail' }); + expect(tokenParams().get('code')).toBe('v1-code'); + expect(tokenParams().get('redirect_uri')).toBe(REDIRECT); + const body = JSON.parse(calls[0].body); + expect(new URLSearchParams(body.query).get('scope')).toContain('urn:ietf:params:oauth:scope:mail'); + }); + + it('registers its own client when the default one is unknown, then signs in with it', async () => { + stub(v1(['swc1.registered'])); + const result = await login('bulwark-webmail', true); + + expect(result).toMatchObject({ ok: true, clientId: 'swc1.registered' }); + const register = JSON.parse(calls.find((c) => c.url === `${SERVER}/auth/register`)!.body); + expect(register.redirect_uris).toEqual([STALWART_LOOPBACK_REDIRECT_URI]); + expect(register.token_endpoint_auth_method).toBe('none'); + expect(tokenParams().get('client_id')).toBe('swc1.registered'); + expect(tokenParams().get('redirect_uri')).toBe(STALWART_LOOPBACK_REDIRECT_URI); + expect(tokenParams().get('client_secret')).toBeNull(); + }); + + it('reuses the registered client for the next sign-in', async () => { + stub(v1(['swc1.registered'])); + await login('bulwark-webmail', true); + calls = []; + await login('bulwark-webmail', true); + + expect(calls.map((c) => c.url)).toEqual([`${SERVER}/api/auth`, `${SERVER}/auth/token`]); + }); + + it('does not register for an admin-configured client', async () => { + stub(v1([])); + const result = await login('configured-client'); + + expect(result).toMatchObject({ ok: false, error: 'login_rejected', detail: 'invalid_client: Unknown client id' }); + expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); + }); + + it('reports an authorization error the server sends to the redirect URI', async () => { + stub((call) => call.url === `${SERVER}/api/auth` + ? Response.json({ type: 'redirect', location: `${REDIRECT}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed&state=x` }) + : v1(['webmail'])(call)); + const result = await login('webmail'); + + expect(result).toMatchObject({ ok: false, error: 'login_rejected', detail: 'invalid_scope: The requested scope is not allowed' }); + }); + + it('keeps the credential verdicts', async () => { + stub(() => Response.json({ type: 'mfaRequired' })); + expect(await login('webmail')).toMatchObject({ ok: false, error: 'totp_required' }); + stub(() => Response.json({ type: 'failure' })); + expect(await login('webmail')).toMatchObject({ ok: false, error: 'invalid_credentials' }); + }); +}); + +describe('stalwartPasswordLogin on Stalwart 0.16', () => { + it('signs in with the default client and the field-style request', async () => { + stub(v016); + const result = await login('bulwark-webmail', true); + + expect(result).toMatchObject({ ok: true, clientId: 'bulwark-webmail' }); + const body = JSON.parse(calls[0].body); + expect(body).toMatchObject({ clientId: 'bulwark-webmail', redirectUri: REDIRECT, codeChallengeMethod: 'S256', mfaToken: '123456' }); + expect(tokenParams().get('code')).toBe('old-code'); + expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); + }); +}); + +describe('stalwartDefaultClientId', () => { + it('keeps the default client on 0.16, which has no client check', async () => { + stub(v016); + expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('bulwark-webmail'); + expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); + }); + + it('names the registered client on 1.0, where the default one is unknown', async () => { + stub(v1(['swc1.registered'])); + expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('swc1.registered'); + }); + + it('keeps a default client the admin registered on 1.0', async () => { + stub(v1(['bulwark-webmail'])); + expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('bulwark-webmail'); + }); +}); diff --git a/lib/auth/pair-grant.ts b/lib/auth/pair-grant.ts index 0ce876e0a..9d482006b 100644 --- a/lib/auth/pair-grant.ts +++ b/lib/auth/pair-grant.ts @@ -70,14 +70,14 @@ export interface PasswordGrantOptions { now?: Date; } -function configuredClient(serverId: string | null): { clientId: string; clientSecret: string } { - let clientId = DEFAULT_CLIENT_ID; +function configuredClient(serverId: string | null): { clientId: string; clientSecret: string; isDefault: boolean } { + let clientId = ''; try { - clientId = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).clientId; + clientId = getRequiredConfig(serverId).clientId; } catch { - // No server configured (custom endpoints only): the default client. + // None configured (or custom endpoints only): the default client. } - return { clientId, clientSecret: getClientSecret(serverId) }; + return { clientId: clientId || DEFAULT_CLIENT_ID, clientSecret: getClientSecret(serverId), isDefault: !clientId }; } function oauthGrant( @@ -158,9 +158,11 @@ export async function mintGrantWithPassword(opts: PasswordGrantOptions): Promise clientSecret: configured.clientSecret, redirectUri: opts.webmailRedirectUri, trusted: opts.trusted, + // As the password+TOTP login: Stalwart 1.0 knows no default client. + registerUnknownClient: configured.isDefault, }); if (second.ok) { - return { ok: true, grant: oauthGrant(opts, second, configured.clientId, configured.clientSecret !== '') }; + return { ok: true, grant: oauthGrant(opts, second, second.clientId, configured.clientSecret !== '' && second.clientId === configured.clientId) }; } const secondVerdict = verdict(second); if (secondVerdict) return secondVerdict; diff --git a/lib/auth/stalwart-login-request.ts b/lib/auth/stalwart-login-request.ts new file mode 100644 index 000000000..15078ec77 --- /dev/null +++ b/lib/auth/stalwart-login-request.ts @@ -0,0 +1,124 @@ +/** + * Request and answer of Stalwart's structured login endpoint (POST /api/auth), + * shared by the server-side password+TOTP login and the Lite build. Kept free + * of server imports so the browser bundle can use it. + * + * The two server lines speak different dialects: + * - 0.16 takes the authorization parameters as fields (`clientId`, + * `redirectUri`, `codeChallenge`, ...) and answers + * `{ type: "authenticated", client_code }`. + * - 1.0 takes the authorization request as a query string (`query`), wants + * `consent: true` for any client it doesn't host itself, and answers + * `{ type: "authenticated", location }`, the redirect URI carrying the code. + * It refuses a body without `query` ("JSON deserialization failed"). + * Neither rejects fields it doesn't know, so one body serves both. + */ + +/** + * The data scopes Stalwart 1.0 grants per client. A token issued to a client + * Stalwart doesn't treat as first-party reaches only what these name (no + * scope: nothing but the session); 0.16 ignores them. + */ +export const STALWART_LOGIN_SCOPE = [ + 'openid', + 'offline_access', + 'urn:ietf:params:oauth:scope:mail', + 'urn:ietf:params:oauth:scope:contacts', + 'urn:ietf:params:oauth:scope:calendars', + 'urn:ietf:params:oauth:scope:files', +].join(' '); + +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 their password into this client'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 { + try { + return new URL(location).searchParams; + } catch { + return null; + } +} + +/** 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': { + 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' }; + } +} diff --git a/lib/auth/stalwart-password-login.ts b/lib/auth/stalwart-password-login.ts index 0fe7125a6..633eb4104 100644 --- a/lib/auth/stalwart-password-login.ts +++ b/lib/auth/stalwart-password-login.ts @@ -1,7 +1,12 @@ import { logger } from '@/lib/logger'; import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard'; -import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce'; +import { generateCodeVerifier, generateCodeChallenge, generateState } from '@/lib/oauth/pkce'; import { forwardedClientHeaders } from '@/lib/auth/client-address'; +import { + STALWART_LOGIN_SCOPE, + readStalwartLoginResponse, + stalwartLoginBody, +} from '@/lib/auth/stalwart-login-request'; /** * Exchange a password + (optional) TOTP code for OAuth tokens at Stalwart. @@ -18,15 +23,10 @@ import { forwardedClientHeaders } from '@/lib/auth/client-address'; * * Used by the password+TOTP login (/api/auth/totp-token-exchange) and by the * password step-up that mints a paired phone's own grant (/api/auth/pair). + * The request and answer differ between Stalwart 0.16 and 1.0, see + * lib/auth/stalwart-login-request.ts. */ -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; -} - export interface StalwartPasswordLoginOptions { serverUrl: string; username: string; @@ -48,6 +48,12 @@ export interface StalwartPasswordLoginOptions { * client rather than against the webmail (see lib/auth/client-address.ts). */ clientIp?: string; + /** + * `clientId` is the built-in default, not one the admin registered. Stalwart + * 1.0 knows no such client, so the login registers its own (see + * {@link stalwartDefaultClient}) and signs in with that. + */ + registerUnknownClient?: boolean; } export interface StalwartTokens { @@ -57,13 +63,15 @@ export interface StalwartTokens { } export type StalwartPasswordLoginResult = - | { ok: true; tokens: StalwartTokens; tokenEndpoint: string } + /** `clientId`: the client the tokens belong to; refreshes must present it. */ + | { ok: true; tokens: StalwartTokens; tokenEndpoint: string; clientId: string } | { ok: false; /** * - `login_endpoint_missing`: no structured login (404) - older server. - * - `login_rejected`: /api/auth refused the request itself (HTTP error): - * an unregistered client or redirect, bad PKCE; Stalwart does not say. + * - `login_rejected`: /api/auth refused the request itself: an + * unregistered client or redirect, a scope the client may not have, + * bad PKCE (0.16 answers an HTTP error and does not say which). * - `totp_required` / `invalid_credentials`: the credential verdict. */ error: @@ -120,35 +128,163 @@ async function fetchConfiguredServer(input: string, init: RequestInit): Promise< return fetch(next, { ...init, redirect: 'error' }); } -export async function stalwartPasswordLogin( - opts: StalwartPasswordLoginOptions, -): Promise { - const base = trimUrl(opts.serverUrl); - const upstreamFetch: typeof fetch = opts.trusted +function upstreamFetchFor(trusted: boolean): typeof fetch { + return trusted ? ((input, init) => fetchConfiguredServer(String(input), init ?? {})) : ((input, init) => fetchPublicUrl(String(input), init as Parameters[1]) as unknown as Promise); +} - // PKCE proves the token exchange originates from the same client that - // initiated the login, so no client secret is required for public clients. - const verifier = generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); +/** + * Redirect URI of the client the login registers at Stalwart 1.0 (below). No + * browser is ever sent there: /api/auth hands the redirect location back in + * its answer and the code is read from it. 1.0 lets a public client register + * only a loopback or private-use redirect URI. + */ +export const STALWART_LOOPBACK_REDIRECT_URI = 'http://127.0.0.1/bulwark-webmail/callback'; - // Step 1: structured login with a separate MFA token. - let login: LoginResult; +interface LoginClient { + clientId: string; + /** Set for the registered client, which may only use this one. */ + redirectUri?: string; +} + +const DEFAULT_CLIENT_TTL_MS = 10 * 60 * 1000; +// Sign-out waits for the client check, so it must not hang. +const CLIENT_CHECK_TIMEOUT_MS = 5000; +const DEFAULT_CLIENT_CACHE_MAX = 64; +const defaultClients = new Map(); + +function cachedDefaultClient(base: string): LoginClient | null { + const entry = defaultClients.get(base); + if (!entry) return null; + if (entry.expiresAt > Date.now()) return entry.client; + defaultClients.delete(base); + return null; +} + +function rememberDefaultClient(base: string, client: LoginClient): void { + if (!defaultClients.has(base) && defaultClients.size >= DEFAULT_CLIENT_CACHE_MAX) { + const oldest = defaultClients.keys().next().value; + if (oldest !== undefined) defaultClients.delete(oldest); + } + defaultClients.set(base, { client, expiresAt: Date.now() + DEFAULT_CLIENT_TTL_MS }); +} + +/** Test hook. */ +export function resetStalwartDefaultClients(): void { + defaultClients.clear(); +} + +/** + * Registers Bulwark as a public client (RFC 7591), which Stalwart 1.0 allows + * without credentials. Its client ids are stateless, sealed with the server's + * key: the same metadata registers to the same id again, so nothing has to be + * stored or cleaned up. Tokens for it reach mail, contacts, calendars and + * files (the scopes it registers), but not the account's Stalwart settings + * (password, TOTP, app passwords): only a first-party client gets those. + */ +async function registerPublicClient(base: string, upstreamFetch: typeof fetch): Promise { + try { + const response = await upstreamFetch(`${base}/auth/register`, { + method: 'POST', + signal: AbortSignal.timeout(CLIENT_CHECK_TIMEOUT_MS), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Bulwark Webmail', + redirect_uris: [STALWART_LOOPBACK_REDIRECT_URI], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope: STALWART_LOGIN_SCOPE, + }), + }); + if (!response.ok) { + logger.warn('Stalwart password login: client registration refused', { status: response.status }); + return null; + } + const body = (await response.json()) as { client_id?: unknown }; + if (typeof body.client_id !== 'string' || !body.client_id) return null; + const client = { clientId: body.client_id, redirectUri: STALWART_LOOPBACK_REDIRECT_URI }; + rememberDefaultClient(base, client); + return client; + } catch (err) { + logger.warn('Stalwart password login: client registration failed', { error: err instanceof Error ? err.message : String(err) }); + return null; + } +} + +/** + * The client that tokens from a login with the built-in default client id + * belong to. Stalwart 0.16 accepts any client id; 1.0 only one it knows, so + * there the login registers its own (see {@link stalwartPasswordLogin}). + * Refreshing and revoking those tokens must present the same id: this asks + * 1.0's client check (GET /api/auth/client, which 0.16 doesn't have) whether + * the server knows `defaultClientId`, and registers when it doesn't. + */ +export async function stalwartDefaultClientId( + serverUrl: string, + defaultClientId: string, + trusted: boolean, +): Promise { + const base = trimUrl(serverUrl); + const cached = cachedDefaultClient(base); + if (cached) return cached.clientId; + const upstreamFetch = upstreamFetchFor(trusted); + const query = new URLSearchParams({ + response_type: 'code', + client_id: defaultClientId, + redirect_uri: STALWART_LOOPBACK_REDIRECT_URI, + state: 'probe', + code_challenge: 'A'.repeat(43), + code_challenge_method: 'S256', + }); + let unknown: boolean; + try { + const response = await upstreamFetch(`${base}/api/auth/client?${query}`, { + method: 'GET', + signal: AbortSignal.timeout(CLIENT_CHECK_TIMEOUT_MS), + }); + const body = response.ok ? ((await response.json().catch(() => null)) as { error?: unknown } | null) : null; + unknown = body?.error === 'invalid_client'; + } catch { + // Unreachable: the request that asked fails on its own. + return defaultClientId; + } + if (unknown) { + const registered = await registerPublicClient(base, upstreamFetch); + return registered?.clientId ?? defaultClientId; + } + rememberDefaultClient(base, { clientId: defaultClientId }); + return defaultClientId; +} + +type LoginStep = + | { ok: true; code: string } + | (Extract & { rejection?: string }); + +/** POST /api/auth: the credentials for an authorization code. */ +async function requestLoginCode( + base: string, + upstreamFetch: typeof fetch, + opts: StalwartPasswordLoginOptions, + client: LoginClient, + challenge: string, +): Promise { + const state = generateState(); + let body: unknown; try { const loginResponse = await upstreamFetch(`${base}/api/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...forwardedClientHeaders(opts.clientIp) }, - body: JSON.stringify({ - type: 'authCode', - accountName: opts.username, - accountSecret: opts.password, - ...(opts.totp ? { mfaToken: opts.totp } : {}), - clientId: opts.clientId, - redirectUri: opts.redirectUri, + body: JSON.stringify(stalwartLoginBody({ + username: opts.username, + password: opts.password, + totp: opts.totp, + clientId: client.clientId, + redirectUri: client.redirectUri ?? opts.redirectUri, codeChallenge: challenge, - codeChallengeMethod: 'S256', - }), + state, + })), }); if (!loginResponse.ok) { @@ -162,7 +298,7 @@ export async function stalwartPasswordLogin( }; } - login = await loginResponse.json(); + body = await loginResponse.json(); } catch (err) { if (err instanceof DisallowedUrlError) { logger.warn('Stalwart password login: rejected non-public server URL at connect time'); @@ -172,34 +308,67 @@ export async function stalwartPasswordLogin( return { ok: false, error: 'login_unreachable' }; } - switch (login.type) { - case 'authenticated': - break; - case 'mfaRequired': + const outcome = readStalwartLoginResponse(body, state); + switch (outcome.kind) { + case 'code': + return { ok: true, code: outcome.code }; + case 'mfa_required': return { ok: false, error: 'totp_required' }; - case 'failure': - default: + case 'invalid_credentials': return { ok: false, error: 'invalid_credentials' }; + case 'rejected': + logger.warn('Stalwart password login: authorization refused', { error: outcome.error }); + return { + ok: false, + error: 'login_rejected', + rejection: outcome.error, + ...(opts.trusted ? { detail: [outcome.error, outcome.description].filter(Boolean).join(': ') } : {}), + }; } +} - if (!login.client_code) { - logger.warn('Stalwart password login: authenticated response missing client_code'); - return { ok: false, error: 'login_failed' }; +export async function stalwartPasswordLogin( + opts: StalwartPasswordLoginOptions, +): Promise { + const base = trimUrl(opts.serverUrl); + const upstreamFetch = upstreamFetchFor(opts.trusted); + + // PKCE proves the token exchange originates from the same client that + // initiated the login, so no client secret is required for public clients. + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + // Step 1: structured login with a separate MFA token. + let client: LoginClient = (opts.registerUnknownClient && cachedDefaultClient(base)) || { clientId: opts.clientId }; + let login = await requestLoginCode(base, upstreamFetch, opts, client, challenge); + // Stalwart 1.0 checks the client before the credentials, so the first + // attempt costs no failed sign-in. + if (!login.ok && login.rejection === 'invalid_client' && opts.registerUnknownClient && !client.redirectUri) { + const registered = await registerPublicClient(base, upstreamFetch); + if (registered) { + client = registered; + login = await requestLoginCode(base, upstreamFetch, opts, client, challenge); + } + } + if (!login.ok) { + const { rejection: _rejection, ...failure } = login; + return failure; } // Step 2: exchange the authorization code for tokens. const tokenEndpoint = `${base}/auth/token`; const tokenParams = new URLSearchParams({ grant_type: 'authorization_code', - code: login.client_code, - client_id: opts.clientId, - redirect_uri: opts.redirectUri, + code: login.code, + client_id: client.clientId, + redirect_uri: client.redirectUri ?? opts.redirectUri, code_verifier: verifier, }); // Confidential clients still send their secret; harmless for public clients. // The secret is the admin's, for the admin's server: a user-chosen server - // (allowCustomJmapEndpoint) never receives it. - if (opts.clientSecret && opts.trusted) tokenParams.set('client_secret', opts.clientSecret); + // (allowCustomJmapEndpoint) never receives it. The client registered above + // has none. + if (opts.clientSecret && opts.trusted && !client.redirectUri) tokenParams.set('client_secret', opts.clientSecret); let tokens: { access_token?: string; expires_in?: number; refresh_token?: string }; try { @@ -233,5 +402,6 @@ export async function stalwartPasswordLogin( ok: true, tokens: { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token }, tokenEndpoint, + clientId: client.clientId, }; } diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index 8c6c27598..81db3d21d 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -5,12 +5,14 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { readFileEnv } from '@/lib/read-file-env'; import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers'; +import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login'; // Fallback OAuth client id used when no client is configured. The password+TOTP // login route mints tokens against the mail server's built-in OAuth with this -// id (Stalwart accepts any client id unless `require_client_registration` is -// enabled), so refreshing/revoking those tokens must fall back to the same id -// instead of failing on the missing OAUTH_CLIENT_ID (#873). +// id (Stalwart 0.16 accepts any client id unless `require_client_registration` +// is enabled; 1.0 gets a client of its own, see defaultClientOptions), so +// refreshing/revoking those tokens must fall back to the same id instead of +// failing on the missing OAUTH_CLIENT_ID (#873). export const DEFAULT_CLIENT_ID = 'bulwark-webmail'; export interface ClientConfigOptions { @@ -126,6 +128,18 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl }; } +/** + * Options for refreshing or revoking a token the password+TOTP login may have + * minted without a configured client. On Stalwart 1.0 that login signs in + * with a client it registers itself (lib/auth/stalwart-password-login.ts), + * whose id the refresh must present instead of the default one. + */ +export async function defaultClientOptions(serverId?: string | null): Promise { + const { clientId, serverUrl } = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + if (clientId !== DEFAULT_CLIENT_ID) return { fallbackClientId: DEFAULT_CLIENT_ID }; + return { fallbackClientId: await stalwartDefaultClientId(serverUrl, DEFAULT_CLIENT_ID, true) }; +} + export function getClientSecret(serverId?: string | null): string { const entry = getServerEntry(serverId); if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;