From 32b2afee3bbea85d149533251d6c45300a555d03 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:12:47 +0200 Subject: [PATCH] fix: stop sending the webmail's OAuth client secret to user-chosen mail servers With custom JMAP endpoints allowed, the password+TOTP login posted the admin's client secret to `/auth/token` on whatever server the user named. The secret now only goes to admin-configured servers. --- .../totp-token-exchange-detail.test.ts | 38 +++++++++++++++++++ lib/auth/stalwart-password-login.ts | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/totp-token-exchange-detail.test.ts b/lib/__tests__/totp-token-exchange-detail.test.ts index 19dc16714..57111caae 100644 --- a/lib/__tests__/totp-token-exchange-detail.test.ts +++ b/lib/__tests__/totp-token-exchange-detail.test.ts @@ -65,3 +65,41 @@ describe('totp-token-exchange error detail', () => { expect((await res.json()).detail).toBe(SECRET_BODY); }); }); + +describe('totp-token-exchange client secret', () => { + // The webmail's OAuth client secret belongs to the admin's own server. A + // user-chosen server (custom JMAP endpoints) must never receive it. + function stalwart() { + const bodies: string[] = []; + const handler = vi.fn(async (url: unknown, init?: { body?: unknown }) => { + bodies.push(String(init?.body ?? '')); + return String(url).endsWith('/api/auth') + ? Response.json({ type: 'authenticated', client_code: 'code-1' }) + : Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }); + }); + return { bodies, handler }; + } + + it('does not send the client secret to a user-chosen server', async () => { + config.allowCustomJmapEndpoint = true; + config.oauthClientSecret = 'webmail-client-secret'; + const { bodies, handler } = stalwart(); + upstream.mockImplementation(handler); + const { POST } = await import('@/app/api/auth/totp-token-exchange/route'); + const res = await POST(login('https://somewhere.example')); + expect(res.status).toBe(200); + expect(bodies).toHaveLength(2); + expect(bodies.join(' ')).not.toContain('webmail-client-secret'); + }); + + it('still sends it to the configured server', async () => { + config.jmapServerUrl = 'https://mail.example.org'; + config.oauthClientSecret = 'webmail-client-secret'; + const { bodies, handler } = stalwart(); + vi.stubGlobal('fetch', handler); + const { POST } = await import('@/app/api/auth/totp-token-exchange/route'); + const res = await POST(login('https://mail.example.org')); + expect(res.status).toBe(200); + expect(new URLSearchParams(bodies[1]).get('client_secret')).toBe('webmail-client-secret'); + }); +}); diff --git a/lib/auth/stalwart-password-login.ts b/lib/auth/stalwart-password-login.ts index e74b7646f..a79279b7e 100644 --- a/lib/auth/stalwart-password-login.ts +++ b/lib/auth/stalwart-password-login.ts @@ -166,7 +166,9 @@ export async function stalwartPasswordLogin( code_verifier: verifier, }); // Confidential clients still send their secret; harmless for public clients. - if (opts.clientSecret) tokenParams.set('client_secret', opts.clientSecret); + // 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); let tokens: { access_token?: string; expires_in?: number; refresh_token?: string }; try {