fix: sign in when stalwart 1.0.1 asks for consent instead of reporting a wrong password

This commit is contained in:
Linus Rath
2026-10-31 03:53:25 +01:00
parent d812caf025
commit 1bef47199c
3 changed files with 156 additions and 17 deletions
@@ -53,9 +53,15 @@ import {
DEFAULT_CLIENT_ID,
MOBILE_CLIENT_ID,
REGISTERED_REDIRECT_URI,
STALWART_LOGIN_SCOPE,
answerConsent,
exchangePasswordForTokens,
generateCodeChallenge,
generateCodeVerifier,
readStalwartLoginResponse,
resolveLoginClient,
resetLoginClients,
stalwartLoginBody,
} from '../../lib/totp-login';
import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native';
import {
@@ -277,6 +283,69 @@ live('live Stalwart sign-in', () => {
});
});
it('1.0.1: a consentRequired answer is met with authConsent and signs in', async () => {
if (!isV1) return;
// A client of its own (1.0 ids are sealed, so registering stores
// nothing) has no grant yet, so leaving `consent` out gets the question.
const reg = await fetch(`${SERVER}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: `Bulwark Mobile consent check ${Date.now()}`,
redirect_uris: [REGISTERED_REDIRECT_URI],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: STALWART_LOGIN_SCOPE,
}),
});
const { client_id: clientId } = (await reg.json()) as { client_id: string };
const verifier = generateCodeVerifier();
const state = 'consent-state';
const body = stalwartLoginBody({
username: USER,
password: PASS,
clientId,
redirectUri: REGISTERED_REDIRECT_URI,
codeChallenge: generateCodeChallenge(verifier),
state,
});
delete body.consent;
const first = await fetch(`${SERVER}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const asked = readStalwartLoginResponse(await first.json(), state);
expect(asked.kind).toBe('consent_required');
if (asked.kind !== 'consent_required') return;
const answered = await answerConsent(SERVER, asked.token, String(body.query), state);
expect(answered.kind).toBe('code');
if (answered.kind !== 'code') return;
const tokenRes = await fetch(`${SERVER}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: answered.code,
client_id: clientId,
redirect_uri: REGISTERED_REDIRECT_URI,
code_verifier: verifier,
}).toString(),
});
const tokens = (await tokenRes.json()) as { access_token?: string; refresh_token?: string };
expect(tokens.access_token).toBeTruthy();
expect(await sessionStatus(tokens.access_token!)).toBe(200);
await revokeRefreshToken(SERVER, {
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token,
tokenEndpoint: `${SERVER}/auth/token`,
clientId,
source: 'totp',
});
}, 30_000);
it('account security with the password (Basic) works on both', async () => {
const client = new JMAPClient();
await client.connect(SERVER, USER, PASS);
+25
View File
@@ -183,6 +183,31 @@ describe('exchangePasswordForTokens', () => {
expect(describeLoginError(err, { serverUrl: BASE }).title).toBe("mail.example.com didn't accept the app");
});
it('answers a consentRequired with authConsent for the same request, instead of calling the password wrong', async () => {
let asked = 0;
const calls = fakeServer('1.0', (q) => {
asked += 1;
return asked === 1
? { type: 'consentRequired', token: 'consent-token' }
: { type: 'authenticated', location: `${q.get('redirect_uri')}?code=code-consent&state=${q.get('state')}` };
});
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
expect(tokens.accessToken).toBe('at');
const [first, second] = authCalls(calls).map((c) => JSON.parse(c.body!));
expect(second).toEqual({ type: 'authConsent', token: 'consent-token', query: first.query, consent: true });
expect(tokenCall(calls).get('code')).toBe('code-consent');
});
it('does not answer a second consentRequired, and does not call it a wrong password', async () => {
const calls = fakeServer('1.0', () => ({ type: 'consentRequired', token: 't' }));
const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e);
expect(err).toBeInstanceOf(TotpLoginError);
expect(err.code).not.toBe('invalid_credentials');
expect(authCalls(calls)).toHaveLength(2);
});
it('maps the credential verdicts of 1.0', async () => {
fakeServer('1.0', () => ({ type: 'failure' }));
expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('invalid_credentials');
+62 -17
View File
@@ -19,6 +19,9 @@
// `consent: true`, knows only registered clients and answers
// `{ type: "authenticated", location }`, the redirect URI carrying the code.
// A body without `query` gets a 400 "JSON deserialization failed".
// 1.0.1 may answer `{ type: "consentRequired", token }` instead (no
// `consent` sent and no grant yet); the app then posts `authConsent` with
// the token and the same query (readStalwartLoginResponse, answerConsent).
// Neither rejects fields it doesn't know, so one body serves both.
//
// Token auth also survives TOTP rotation, unlike Basic auth, which would have
@@ -331,7 +334,12 @@ export type StalwartLoginOutcome =
* client, redirect URI not registered, scope not allowed), before looking
* at the credentials.
*/
| { kind: 'rejected'; error: string; description?: string };
| { kind: 'rejected'; error: string; description?: string }
/**
* Stalwart 1.0.1 wants the user's consent for this client first: answered
* with an `authConsent` request carrying `token` (answerConsent).
*/
| { kind: 'consent_required'; token: string };
function locationParams(location: string): URLSearchParams | null {
// A private-use scheme isn't something every URL parser takes apart, so
@@ -360,6 +368,11 @@ export function readStalwartLoginResponse(body: unknown, state: string): Stalwar
}
case 'mfaRequired':
return { kind: 'mfa_required' };
case 'consentRequired':
// It used to fall through to "wrong password".
return typeof res.token === 'string' && res.token
? { kind: 'consent_required', token: res.token }
: { kind: 'rejected', error: 'consent_required' };
case 'invalid':
return {
kind: 'rejected',
@@ -381,31 +394,19 @@ export function readStalwartLoginResponse(body: unknown, state: string): Stalwar
}
}
async function requestLoginCode(
async function postLoginRequest(
base: string,
username: string,
password: string,
totp: string | undefined,
client: LoginClient,
challenge: string,
payload: Record<string, unknown>,
state: string,
signal?: AbortSignal,
): Promise<StalwartLoginOutcome> {
const state = randomHex(16);
const url = `${base}/api/auth`;
let loginResponse: Response;
try {
loginResponse = await fetchWithoutForeignRedirect(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(stalwartLoginBody({
username,
password,
totp,
clientId: client.clientId,
redirectUri: client.redirectUri,
codeChallenge: challenge,
state,
})),
body: JSON.stringify(payload),
signal,
});
} catch (err) {
@@ -427,6 +428,47 @@ async function requestLoginCode(
return readStalwartLoginResponse(body, state);
}
/**
* Answers Stalwart 1.0.1's `consentRequired` with `authConsent` for the same
* authorization request (`query`). The user typed the password into the
* app's own sign-in form for this account, which is the consent the browser
* page would ask for. A second consentRequired is not answered again.
*/
export async function answerConsent(
base: string,
token: string,
query: string,
state: string,
signal?: AbortSignal,
): Promise<StalwartLoginOutcome> {
const outcome = await postLoginRequest(base, { type: 'authConsent', token, query, consent: true }, state, signal);
return outcome.kind === 'consent_required' ? { kind: 'rejected', error: 'consent_required' } : outcome;
}
async function requestLoginCode(
base: string,
username: string,
password: string,
totp: string | undefined,
client: LoginClient,
challenge: string,
signal?: AbortSignal,
): Promise<StalwartLoginOutcome> {
const state = randomHex(16);
const payload = stalwartLoginBody({
username,
password,
totp,
clientId: client.clientId,
redirectUri: client.redirectUri,
codeChallenge: challenge,
state,
});
const outcome = await postLoginRequest(base, payload, state, signal);
if (outcome.kind !== 'consent_required') return outcome;
return answerConsent(base, outcome.token, String(payload.query), state, signal);
}
/**
* Exchange username + password (+ TOTP) for an OAuth token bundle. Throws
* `LoginEndpointMissingError` when the server predates the structured
@@ -471,6 +513,9 @@ export async function exchangePasswordForTokens(
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
case 'invalid_credentials':
throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code');
case 'consent_required':
// answerConsent never hands this back; for the type checker.
throw new TotpLoginError('login_failed', 'Sign-in refused: consent_required');
case 'rejected':
throw new TotpLoginError(
outcome.error === 'missing_code' || outcome.error === 'state_mismatch' ? 'login_failed' : 'client_rejected',