mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: sign in when stalwart 1.0.1 asks for consent instead of reporting a wrong password
This commit is contained in:
@@ -53,9 +53,15 @@ import {
|
|||||||
DEFAULT_CLIENT_ID,
|
DEFAULT_CLIENT_ID,
|
||||||
MOBILE_CLIENT_ID,
|
MOBILE_CLIENT_ID,
|
||||||
REGISTERED_REDIRECT_URI,
|
REGISTERED_REDIRECT_URI,
|
||||||
|
STALWART_LOGIN_SCOPE,
|
||||||
|
answerConsent,
|
||||||
exchangePasswordForTokens,
|
exchangePasswordForTokens,
|
||||||
|
generateCodeChallenge,
|
||||||
|
generateCodeVerifier,
|
||||||
|
readStalwartLoginResponse,
|
||||||
resolveLoginClient,
|
resolveLoginClient,
|
||||||
resetLoginClients,
|
resetLoginClients,
|
||||||
|
stalwartLoginBody,
|
||||||
} from '../../lib/totp-login';
|
} from '../../lib/totp-login';
|
||||||
import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native';
|
import { discoverOAuthMetadata, loginWithPkce, pkceScopes, revokeRefreshToken } from '../../lib/oauth-native';
|
||||||
import {
|
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 () => {
|
it('account security with the password (Basic) works on both', async () => {
|
||||||
const client = new JMAPClient();
|
const client = new JMAPClient();
|
||||||
await client.connect(SERVER, USER, PASS);
|
await client.connect(SERVER, USER, PASS);
|
||||||
|
|||||||
@@ -183,6 +183,31 @@ describe('exchangePasswordForTokens', () => {
|
|||||||
expect(describeLoginError(err, { serverUrl: BASE }).title).toBe("mail.example.com didn't accept the app");
|
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 () => {
|
it('maps the credential verdicts of 1.0', async () => {
|
||||||
fakeServer('1.0', () => ({ type: 'failure' }));
|
fakeServer('1.0', () => ({ type: 'failure' }));
|
||||||
expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('invalid_credentials');
|
expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('invalid_credentials');
|
||||||
|
|||||||
+62
-17
@@ -19,6 +19,9 @@
|
|||||||
// `consent: true`, knows only registered clients and answers
|
// `consent: true`, knows only registered clients and answers
|
||||||
// `{ type: "authenticated", location }`, the redirect URI carrying the code.
|
// `{ type: "authenticated", location }`, the redirect URI carrying the code.
|
||||||
// A body without `query` gets a 400 "JSON deserialization failed".
|
// 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.
|
// Neither rejects fields it doesn't know, so one body serves both.
|
||||||
//
|
//
|
||||||
// Token auth also survives TOTP rotation, unlike Basic auth, which would have
|
// 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
|
* client, redirect URI not registered, scope not allowed), before looking
|
||||||
* at the credentials.
|
* 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 {
|
function locationParams(location: string): URLSearchParams | null {
|
||||||
// A private-use scheme isn't something every URL parser takes apart, so
|
// 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':
|
case 'mfaRequired':
|
||||||
return { kind: 'mfa_required' };
|
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':
|
case 'invalid':
|
||||||
return {
|
return {
|
||||||
kind: 'rejected',
|
kind: 'rejected',
|
||||||
@@ -381,31 +394,19 @@ export function readStalwartLoginResponse(body: unknown, state: string): Stalwar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestLoginCode(
|
async function postLoginRequest(
|
||||||
base: string,
|
base: string,
|
||||||
username: string,
|
payload: Record<string, unknown>,
|
||||||
password: string,
|
state: string,
|
||||||
totp: string | undefined,
|
|
||||||
client: LoginClient,
|
|
||||||
challenge: string,
|
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<StalwartLoginOutcome> {
|
): Promise<StalwartLoginOutcome> {
|
||||||
const state = randomHex(16);
|
|
||||||
const url = `${base}/api/auth`;
|
const url = `${base}/api/auth`;
|
||||||
let loginResponse: Response;
|
let loginResponse: Response;
|
||||||
try {
|
try {
|
||||||
loginResponse = await fetchWithoutForeignRedirect(url, {
|
loginResponse = await fetchWithoutForeignRedirect(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify(stalwartLoginBody({
|
body: JSON.stringify(payload),
|
||||||
username,
|
|
||||||
password,
|
|
||||||
totp,
|
|
||||||
clientId: client.clientId,
|
|
||||||
redirectUri: client.redirectUri,
|
|
||||||
codeChallenge: challenge,
|
|
||||||
state,
|
|
||||||
})),
|
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -427,6 +428,47 @@ async function requestLoginCode(
|
|||||||
return readStalwartLoginResponse(body, state);
|
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
|
* Exchange username + password (+ TOTP) for an OAuth token bundle. Throws
|
||||||
* `LoginEndpointMissingError` when the server predates the structured
|
* `LoginEndpointMissingError` when the server predates the structured
|
||||||
@@ -471,6 +513,9 @@ export async function exchangePasswordForTokens(
|
|||||||
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
|
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
|
||||||
case 'invalid_credentials':
|
case 'invalid_credentials':
|
||||||
throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code');
|
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':
|
case 'rejected':
|
||||||
throw new TotpLoginError(
|
throw new TotpLoginError(
|
||||||
outcome.error === 'missing_code' || outcome.error === 'state_mismatch' ? 'login_failed' : 'client_rejected',
|
outcome.error === 'missing_code' || outcome.error === 'state_mismatch' ? 'login_failed' : 'client_rejected',
|
||||||
|
|||||||
Reference in New Issue
Block a user