diff --git a/src/api/__tests__/live-stalwart-auth.test.ts b/src/api/__tests__/live-stalwart-auth.test.ts index 299ca430..c810c485 100644 --- a/src/api/__tests__/live-stalwart-auth.test.ts +++ b/src/api/__tests__/live-stalwart-auth.test.ts @@ -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); diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts index a1f4ff9a..f2f5c424 100644 --- a/src/lib/__tests__/totp-login.test.ts +++ b/src/lib/__tests__/totp-login.test.ts @@ -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'); diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index 289d3a3e..b15fa0f1 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -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, + state: string, signal?: AbortSignal, ): Promise { - 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 { + 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 { + 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',