From 7a42a15f8ab990fba15f39e800c1f1378cba4c62 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:57:06 +0100 Subject: [PATCH] feat: sign in through the bulwark-mobile client on stalwart 1.0 (bulwarkmail/webmail#1459) --- src/api/__tests__/live-stalwart-auth.test.ts | 42 ++++++-- src/lib/__tests__/totp-login.test.ts | 58 +++++++++-- src/lib/totp-login.ts | 102 ++++++++++++------- 3 files changed, 150 insertions(+), 52 deletions(-) diff --git a/src/api/__tests__/live-stalwart-auth.test.ts b/src/api/__tests__/live-stalwart-auth.test.ts index 35b1e06b..299ca430 100644 --- a/src/api/__tests__/live-stalwart-auth.test.ts +++ b/src/api/__tests__/live-stalwart-auth.test.ts @@ -17,8 +17,10 @@ * started. * * What differs between the lines (checked on 0.16.27 and 1.0.0): - * - 1.0 knows only registered clients, so the app registers its own public - * client (RFC 7591); 0.16 takes `bulwark-webmail`. + * - 1.0 knows only registered clients, so the app signs in as + * `bulwark-mobile` where the webmail's admin OAuth setup registered it + * (webmail #1459), else registers its own public client (RFC 7591); 0.16 + * takes `bulwark-webmail`. The run checks whichever the server has. * - 1.0 rotates refresh tokens and revokes the whole grant when a superseded * one comes back after its successor was used. * - 1.0 has a revocation endpoint; 0.16 has none. @@ -49,6 +51,7 @@ import { } from '../account-security'; import { DEFAULT_CLIENT_ID, + MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, exchangePasswordForTokens, resolveLoginClient, @@ -116,6 +119,14 @@ async function nextTotpCode(): Promise { // Stalwart 1.0 is the line with RP-initiated logout; 0.16 has none. let isV1 = false; +// A 1.0 where the webmail's admin OAuth setup registered `bulwark-mobile`. +let hasMobileClient = false; + +/** The client a 1.0 sign-in must have used. */ +function expectV1Client(clientId: string | null | undefined): void { + if (hasMobileClient) expect(clientId).toBe(MOBILE_CLIENT_ID); + else expect(clientId).toMatch(/^swc1\./); +} function useSingleton(client: JMAPClient): void { (jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot()); @@ -140,6 +151,18 @@ live('live Stalwart sign-in', () => { beforeAll(async () => { const metadata = await discoverOAuthMetadata(SERVER); isV1 = !!(metadata as { end_session_endpoint?: string } | null)?.end_session_endpoint; + if (isV1) { + const query = new URLSearchParams({ + response_type: 'code', + client_id: MOBILE_CLIENT_ID, + redirect_uri: REGISTERED_REDIRECT_URI, + state: 's', + code_challenge: 'A'.repeat(43), + code_challenge_method: 'S256', + }); + const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string }; + hasMobileClient = !check.error; + } // Optional so the suite also runs against older sign-in code, to show // what it does on a server. resetLoginClients?.(); @@ -166,7 +189,7 @@ live('live Stalwart sign-in', () => { it('signs in with a client the server accepts, on a redirect it accepts', async () => { const client = await resolveLoginClient(SERVER); if (isV1) { - expect(client.clientId).toMatch(/^swc1\./); + expectV1Client(client.clientId); expect(client.redirectUri).toBe(REGISTERED_REDIRECT_URI); // The browser sign-in's authorization request with this client and // the scopes the app asks for passes 1.0's client check. @@ -182,7 +205,8 @@ live('live Stalwart sign-in', () => { }); const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string; scopes?: string[] }; expect(check.error).toBeUndefined(); - expect(check.scopes?.length).toBeGreaterThan(1); + // A first-party client shows as one scope, "Full access to your account". + expect(check.scopes?.length).toBeGreaterThan(hasMobileClient ? 0 : 1); } else { expect(client).toEqual({ clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI }); } @@ -208,7 +232,7 @@ live('live Stalwart sign-in', () => { expect(tokens.accessToken).toBeTruthy(); expect(tokens.refreshToken).toBeTruthy(); expect(tokens.tokenEndpoint).toBe(`${SERVER}/auth/token`); - if (isV1) expect(tokens.clientId).toMatch(/^swc1\./); + if (isV1) expectV1Client(tokens.clientId); else expect(tokens.clientId).toBe(DEFAULT_CLIENT_ID); }); @@ -237,9 +261,9 @@ live('live Stalwart sign-in', () => { expect(await client.forceRefreshToken()).toBe(true); }, 30_000); - it('account security: 1.0 refuses it to the registered client, 0.16 allows it', async () => { + it('account security: 1.0 refuses it to the registered client, allows it to bulwark-mobile; 0.16 allows it', async () => { useSingleton(client); - if (isV1) { + if (isV1 && !hasMobileClient) { // The session hides the extension from this client, and the calls // themselves are refused. expect(isStalwartSupported()).toBe(false); @@ -317,7 +341,7 @@ live('live Stalwart sign-in', () => { const authUrl = new URL(browser.mock.calls[0][0] as string); expect(`${authUrl.origin}${authUrl.pathname}`).toBe(metadata.authorization_endpoint); if (isV1) { - expect(authUrl.searchParams.get('client_id')).toMatch(/^swc1\./); + expectV1Client(authUrl.searchParams.get('client_id')); expect(metadata.registration_endpoint).toBeTruthy(); } else { // 0.16 on :8016 names its public address, not the one typed here, and @@ -355,7 +379,7 @@ live('live Stalwart sign-in', () => { tokenSource: string; }; expect(stored.tokenSource).toBe('totp'); - if (isV1) expect(stored.clientId).toMatch(/^swc1\./); + if (isV1) expectV1Client(stored.clientId); else expect(stored.clientId).toBe(DEFAULT_CLIENT_ID); const res = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['role'] }, '0']]); expect(res.methodResponses[0][0]).toBe('Mailbox/get'); diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts index 7ae53400..05147eb4 100644 --- a/src/lib/__tests__/totp-login.test.ts +++ b/src/lib/__tests__/totp-login.test.ts @@ -7,6 +7,7 @@ import { resolveLoginClient, TotpLoginError, REGISTERED_REDIRECT_URI, + MOBILE_CLIENT_ID, } from '../totp-login'; import { describeLoginError } from '../login-errors'; @@ -31,19 +32,28 @@ function json(body: unknown, status = 200): Response { 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 +// registered (ids `swc1.`) and those in `admin` (registered by an admin +// for the given redirect URI), 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) { +function fakeServer( + version: '0.16' | '1.0', + answer?: (query: URLSearchParams) => unknown, + admin: Record = {}, +) { const calls: Call[] = []; let registered = 0; + const knows = (clientId: string, redirectUri: string | null) => + clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri); 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 }); + const params = new URL(url).searchParams; + return json(knows(params.get('client_id') ?? '', params.get('redirect_uri')) + ? { 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); @@ -56,7 +66,7 @@ function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) = 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' }); + if (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) 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`, @@ -111,6 +121,21 @@ describe('exchangePasswordForTokens', () => { expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); }); + it('signs in on Stalwart 1.0 through the bulwark-mobile client the webmail setup registered (webmail #1459)', async () => { + const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }); + const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); + + expect(tokens.clientId).toBe(MOBILE_CLIENT_ID); + expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); + const query = new URLSearchParams(JSON.parse(authCalls(calls)[0].body!).query); + expect(query.get('client_id')).toBe(MOBILE_CLIENT_ID); + expect(query.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); + expect(tokenCall(calls).get('client_id')).toBe(MOBILE_CLIENT_ID); + expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI); + // A public client: there is no secret to send. + expect(tokenCall(calls).has('client_secret')).toBe(false); + }); + it('keeps the registered client per server for the next sign-in', async () => { fakeServer('1.0'); await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); @@ -289,6 +314,27 @@ describe('resolveLoginClient', () => { const client = await resolveLoginClient(BASE, { registrationEndpoint: 'https://public.example.com/auth/register' }); expect(client.clientId).toBe('swc1.pub'); - expect(urls).toEqual([`${BASE}/api/auth/client`, 'https://public.example.com/auth/register']); + expect(urls).toEqual([`${BASE}/api/auth/client`, `${BASE}/api/auth/client`, 'https://public.example.com/auth/register']); + }); + + it('asks for bulwark-mobile first, then the webmail client, before registering one', async () => { + const calls = fakeServer('1.0'); + await resolveLoginClient(BASE); + + const checked = calls + .filter((c) => c.url.startsWith(`${BASE}/api/auth/client`)) + .map((c) => new URL(c.url).searchParams); + expect(checked.map((p) => [p.get('client_id'), p.get('redirect_uri')])).toEqual([ + [MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI], + ['bulwark-webmail', 'bulwarkmobile://auth/callback'], + ]); + }); + + it('remembers bulwark-mobile for the next sign-in', async () => { + fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }); + await resolveLoginClient(BASE); + const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI }); + expect((await resolveLoginClient(BASE)).clientId).toBe(MOBILE_CLIENT_ID); + expect(calls).toHaveLength(0); }); }); diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index 1f2d56a5..2c00a39e 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -36,14 +36,23 @@ 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. + * no client for it, and of `bulwark-mobile`. 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 App Store and + * Play 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 app's own client, which the webmail's admin OAuth setup registers on + * Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach + * the account's Stalwart settings, which those of a client the app + * registers itself don't. + */ +export const MOBILE_CLIENT_ID = 'bulwark-mobile'; + /** * 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); @@ -189,27 +198,20 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`) } /** - * 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, at `registrationEndpoint` when the OAuth - * metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as - * before. A server having a bad moment (5xx, 429) gets the default without - * it being remembered; an unreachable one throws `NetworkError`, as the - * default would only end in the server refusing a client it doesn't know. + * Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't + * have): does the server take `clientId` on `redirectUri`? `busy` for a + * server having a bad moment (5xx, 429), `none` where there is no such + * check; an unreachable server throws `NetworkError`. */ -export async function resolveLoginClient( - serverUrl: string, - opts?: { registrationEndpoint?: 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 }; +async function checkClient( + base: string, + clientId: string, + redirectUri: string, +): Promise<'known' | 'unknown' | 'none' | 'busy'> { const query = new URLSearchParams({ response_type: 'code', - client_id: DEFAULT_CLIENT_ID, - redirect_uri: HANDOFF_REDIRECT_URI, + client_id: clientId, + redirect_uri: redirectUri, state: 'probe', code_challenge: 'A'.repeat(43), code_challenge_method: 'S256', @@ -228,24 +230,50 @@ export async function resolveLoginClient( } finally { t.done(); } - if (!response || typeof response.status !== 'number') return fallback; + if (!response || typeof response.status !== 'number') return 'busy'; assertSameServer(url, response); - if (response.status >= 500 || response.status === 429 || response.status === 408) return fallback; + if (response.status >= 500 || response.status === 429 || response.status === 408) return 'busy'; // 0.16 and servers that aren't Stalwart: no such endpoint. - if (!response.ok) { - rememberClient(base, fallback); - return fallback; - } + if (!response.ok) return 'none'; const body = (await response.json().catch(() => null)) as { error?: unknown } | null; - if (!body || typeof body !== 'object') { - rememberClient(base, fallback); - return fallback; + if (!body || typeof body !== 'object') return 'none'; + // `invalid_client` (unknown id) or a redirect URI the client doesn't list. + return typeof body.error === 'string' && body.error ? 'unknown' : 'known'; +} + +/** + * The OAuth client to sign in to `serverUrl` as. On Stalwart 1.0 that is + * `bulwark-mobile` where the admin's OAuth setup registered it, else the + * webmail's client if it takes the app's redirect URI, else a public client + * the app registers itself, at `registrationEndpoint` when the OAuth + * metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as + * before. A server having a bad moment (5xx, 429) gets the default without + * it being remembered; an unreachable one throws `NetworkError`, as the + * default would only end in the server refusing a client it doesn't know. + */ +export async function resolveLoginClient( + serverUrl: string, + opts?: { registrationEndpoint?: 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 own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI); + if (own === 'busy') return fallback; + if (own === 'known') { + const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI }; + rememberClient(base, client); + return client; } - // `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) { - const endpoint = opts?.registrationEndpoint; - return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined); + if (own === 'unknown') { + const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI); + if (shared === 'busy') return fallback; + if (shared === 'unknown') { + const endpoint = opts?.registrationEndpoint; + return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined); + } } rememberClient(base, fallback); return fallback;