fix: explain a refused token exchange on a two-factor sign-in

This commit is contained in:
Linus Rath
2026-10-31 04:27:27 +01:00
parent 7ecac4ab43
commit 37e72c129f
3 changed files with 59 additions and 0 deletions
@@ -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);
+7
View File
@@ -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);
+9
View File
@@ -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"),