From 37e72c129fcd78bfd3e2764fc3ebac216b5d8dd2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:27:27 +0100 Subject: [PATCH] fix: explain a refused token exchange on a two-factor sign-in --- src/api/__tests__/live-stalwart-auth.test.ts | 43 ++++++++++++++++++++ src/lib/__tests__/login-errors.test.ts | 7 ++++ src/lib/login-errors.ts | 9 ++++ 3 files changed, 59 insertions(+) diff --git a/src/api/__tests__/live-stalwart-auth.test.ts b/src/api/__tests__/live-stalwart-auth.test.ts index c810c485..7dab439b 100644 --- a/src/api/__tests__/live-stalwart-auth.test.ts +++ b/src/api/__tests__/live-stalwart-auth.test.ts @@ -62,7 +62,9 @@ import { resolveLoginClient, resetLoginClients, stalwartLoginBody, + TotpLoginError, } from '../../lib/totp-login'; +import { describeLoginError } from '../../lib/login-errors'; import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native'; import { HANDOFF_REDIRECT_URI, @@ -346,6 +348,47 @@ live('live Stalwart sign-in', () => { }); }, 30_000); + it('a refused token exchange is not reported as a wrong password, on both', async () => { + // A code the server issued, redeemed with the wrong PKCE verifier: the + // token endpoint refuses it after the credentials were accepted. + const client = await resolveLoginClient(SERVER); + const state = 'exchange-state'; + const res = await fetch(`${SERVER}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(stalwartLoginBody({ + username: USER, + password: PASS, + clientId: client.clientId, + redirectUri: client.redirectUri, + codeChallenge: generateCodeChallenge(generateCodeVerifier()), + state, + })), + }); + const outcome = readStalwartLoginResponse(await res.json(), state); + expect(outcome.kind).toBe('code'); + if (outcome.kind !== 'code') return; + const token = await fetch(`${SERVER}/auth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: outcome.code, + client_id: client.clientId, + redirect_uri: client.redirectUri, + code_verifier: generateCodeVerifier(), + }).toString(), + }); + expect(token.ok).toBe(false); + // What exchangePasswordForTokens throws for that answer, and what the + // sign-in screen makes of it. + const copy = describeLoginError( + new TotpLoginError('token_exchange_failed', `Token exchange failed: ${token.status}`), + { serverUrl: SERVER }, + ); + expect(copy.detail).toMatch(/password and code were accepted/); + }, 30_000); + it('account security with the password (Basic) works on both', async () => { const client = new JMAPClient(); await client.connect(SERVER, USER, PASS); diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index 287849e1..cce6ec19 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -24,6 +24,13 @@ describe('describeLoginError', () => { expect(describeLoginError(named('AuthenticationError', 'Invalid credentials')).tone).not.toBe('info'); }); + it('explains a refused token exchange after the password and code were accepted', () => { + const err = Object.assign(named('TotpLoginError', 'Token exchange failed: 400'), { code: 'token_exchange_failed' }); + const copy = describeLoginError(err, { serverUrl: 'https://mail.example.com' }); + expect(copy.title).toBe("mail.example.com didn't start a session"); + expect(copy.detail).toMatch(/password and code were accepted/); + }); + it('blames a refused access token on the token, not on a password', () => { for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) { const copy = describeLoginError(err); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index fb85c738..0ab97a5d 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -141,6 +141,15 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} }; } + // Password and code were fine; the token endpoint then refused the app + // (webmail TOKEN_EXCHANGE_FAILED copy). + if (name === 'TotpLoginError' && (err as { code?: string }).code === 'token_exchange_failed') { + return { + title: t('login.mobile.err_token_exchange_title', "{host} didn't start a session", { host }), + detail: t('login.error.token_exchange_failed', 'Your password and code were accepted, but the mail server refused to start a session for this app. Ask your administrator to check its OAuth client settings.'), + }; + } + if (name === 'TotpLoginError' && lower.includes('invalid')) { return { title: t('login.mobile.err_bad_title', "That didn't work"),