mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: sign in with password and two-factor code on Stalwart 1.0
This commit is contained in:
@@ -553,6 +553,8 @@
|
|||||||
"err_bad_title": "That didn't work",
|
"err_bad_title": "That didn't work",
|
||||||
"err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
|
"err_cert_detail": "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.",
|
||||||
"err_cert_title": "Couldn't verify {host}",
|
"err_cert_title": "Couldn't verify {host}",
|
||||||
|
"err_client_rejected_detail": "The server refused Bulwark as a sign-in client. Ask your administrator to allow it, or sign in with an app password.",
|
||||||
|
"err_client_rejected_title": "{host} didn't accept the app",
|
||||||
"err_code_expired_detail": "Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.",
|
"err_code_expired_detail": "Sign-in codes are good for two minutes. Show a new one in the webmail and scan again.",
|
||||||
"err_code_expired_or_used_detail": "Show a new one in the webmail and scan again.",
|
"err_code_expired_or_used_detail": "Show a new one in the webmail and scan again.",
|
||||||
"err_code_expired_or_used_title": "That code has expired or was already used",
|
"err_code_expired_or_used_title": "That code has expired or was already used",
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../client-cert', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../client-cert')>();
|
||||||
|
return { ...actual, secureFetch: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { secureFetch } from '../client-cert';
|
||||||
|
import {
|
||||||
|
exchangePasswordForTokens,
|
||||||
|
resetLoginClients,
|
||||||
|
resolveLoginClient,
|
||||||
|
TotpLoginError,
|
||||||
|
REGISTERED_REDIRECT_URI,
|
||||||
|
} from '../totp-login';
|
||||||
|
import { describeLoginError } from '../login-errors';
|
||||||
|
|
||||||
|
const fetchMock = secureFetch as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
const BASE = 'https://mail.example.com';
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200, extra?: { url?: string; redirected?: boolean }): Response {
|
||||||
|
const res = new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
if (extra) {
|
||||||
|
Object.defineProperty(res, 'url', { value: extra.url });
|
||||||
|
Object.defineProperty(res, 'redirected', { value: extra.redirected });
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Call { url: string; method: string; body?: string }
|
||||||
|
|
||||||
|
// A fake Stalwart of either line. `v1` knows no client but the ones it
|
||||||
|
// registered (ids `swc1.<n>`), takes `{query, consent}` and answers with a
|
||||||
|
// location; 0.16 has no client check and answers `client_code`.
|
||||||
|
function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) => unknown) {
|
||||||
|
const calls: Call[] = [];
|
||||||
|
let registered = 0;
|
||||||
|
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||||
|
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
|
||||||
|
calls.push(call);
|
||||||
|
const path = url.slice(BASE.length).split('?')[0];
|
||||||
|
if (path === '/api/auth/client') {
|
||||||
|
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
|
||||||
|
const clientId = new URL(url).searchParams.get('client_id') ?? '';
|
||||||
|
return json(clientId.startsWith('swc1.') ? { name: 'Bulwark Mobile' } : { error: 'invalid_client', description: 'Unknown client id', redirect: false });
|
||||||
|
}
|
||||||
|
if (path === '/auth/register') {
|
||||||
|
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
|
||||||
|
registered += 1;
|
||||||
|
return json({ client_id: `swc1.${registered}` }, 201);
|
||||||
|
}
|
||||||
|
if (path === '/api/auth') {
|
||||||
|
const body = JSON.parse(call.body!);
|
||||||
|
if (version === '0.16') return json(answer ? answer(new URLSearchParams()) : { type: 'authenticated', client_code: 'code-016' });
|
||||||
|
if (typeof body.query !== 'string') return json({ title: 'Invalid parameters', detail: 'JSON deserialization failed' }, 400);
|
||||||
|
const query = new URLSearchParams(body.query);
|
||||||
|
if (answer) return json(answer(query));
|
||||||
|
if (!query.get('client_id')?.startsWith('swc1.')) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
|
||||||
|
return json({
|
||||||
|
type: 'authenticated',
|
||||||
|
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path === '/auth/token') {
|
||||||
|
return json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
|
||||||
|
}
|
||||||
|
return json({}, 404);
|
||||||
|
});
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenCall = (calls: Call[]) => new URLSearchParams(calls.find((c) => c.url.endsWith('/auth/token'))!.body);
|
||||||
|
const authCalls = (calls: Call[]) => calls.filter((c) => c.url === `${BASE}/api/auth`);
|
||||||
|
|
||||||
|
describe('exchangePasswordForTokens', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetLoginClients();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs in on Stalwart 0.16 with the webmail client id and the 0.16 fields', async () => {
|
||||||
|
const calls = fakeServer('0.16');
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe('bulwark-webmail');
|
||||||
|
const body = JSON.parse(authCalls(calls)[0].body!);
|
||||||
|
expect(body).toMatchObject({ clientId: 'bulwark-webmail', redirectUri: 'bulwarkmobile://auth/callback', mfaToken: '123456', consent: true });
|
||||||
|
expect(tokenCall(calls).get('code')).toBe('code-016');
|
||||||
|
expect(tokenCall(calls).get('client_id')).toBe('bulwark-webmail');
|
||||||
|
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers its own client on Stalwart 1.0 and signs in with it', async () => {
|
||||||
|
const calls = fakeServer('1.0');
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe('swc1.1');
|
||||||
|
expect(tokens.source).toBe('totp');
|
||||||
|
const register = JSON.parse(calls.find((c) => c.url.endsWith('/auth/register'))!.body!);
|
||||||
|
expect(register.redirect_uris).toEqual([REGISTERED_REDIRECT_URI]);
|
||||||
|
expect(register.token_endpoint_auth_method).toBe('none');
|
||||||
|
expect(register.scope).toContain('urn:ietf:params:oauth:scope:mail');
|
||||||
|
// The password goes out once, with the registered client.
|
||||||
|
expect(authCalls(calls)).toHaveLength(1);
|
||||||
|
const query = new URLSearchParams(JSON.parse(authCalls(calls)[0].body!).query);
|
||||||
|
expect(query.get('client_id')).toBe('swc1.1');
|
||||||
|
expect(query.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
||||||
|
expect(tokenCall(calls).get('code')).toBe('code-10');
|
||||||
|
expect(tokenCall(calls).get('client_id')).toBe('swc1.1');
|
||||||
|
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the registered client per server for the next sign-in', async () => {
|
||||||
|
fakeServer('1.0');
|
||||||
|
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
|
||||||
|
const calls = fakeServer('1.0');
|
||||||
|
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '654321');
|
||||||
|
|
||||||
|
expect(tokens.clientId).toBe('swc1.1');
|
||||||
|
expect(calls.map((c) => c.url.slice(BASE.length).split('?')[0])).toEqual(['/api/auth', '/auth/token']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a code whose state is not the one it sent', async () => {
|
||||||
|
fakeServer('1.0', (q) => ({ type: 'authenticated', location: `${q.get('redirect_uri')}?code=c&state=other` }));
|
||||||
|
const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(TotpLoginError);
|
||||||
|
expect(err.code).toBe('login_failed');
|
||||||
|
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/auth/token'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a refused authorization request as the server refusing the app, not the password', async () => {
|
||||||
|
fakeServer('1.0', (q) => ({
|
||||||
|
type: 'redirect',
|
||||||
|
location: `${q.get('redirect_uri')}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed`,
|
||||||
|
}));
|
||||||
|
const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e);
|
||||||
|
|
||||||
|
expect(err.code).toBe('client_rejected');
|
||||||
|
expect(describeLoginError(err, { serverUrl: BASE }).title).toBe("mail.example.com didn't accept the app");
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
fakeServer('1.0', () => ({ type: 'mfaRequired' }));
|
||||||
|
expect((await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e)).code).toBe('totp_required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not post the password when the server redirects to another host', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
|
calls.push(url);
|
||||||
|
return json({}, 404, { url: 'https://evil.example/api/auth/client', redirected: true });
|
||||||
|
});
|
||||||
|
const err = await exchangePasswordForTokens(BASE, 'u', 'pw', '1').catch((e) => e);
|
||||||
|
|
||||||
|
expect(err).toBeInstanceOf(TotpLoginError);
|
||||||
|
expect(calls.some((u) => u.endsWith('/api/auth'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveLoginClient', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
resetLoginClients();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the webmail client when the server is unreachable, without remembering it', async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce(new TypeError('Network request failed'));
|
||||||
|
expect((await resolveLoginClient(BASE)).clientId).toBe('bulwark-webmail');
|
||||||
|
fakeServer('1.0');
|
||||||
|
expect((await resolveLoginClient(BASE)).clientId).toBe('swc1.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -126,6 +126,15 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {}
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server refused the app itself (Stalwart 1.0 with app registration
|
||||||
|
// turned off, or a client the admin restricted), not the password.
|
||||||
|
if (name === 'TotpLoginError' && (err as { code?: string }).code === 'client_rejected') {
|
||||||
|
return {
|
||||||
|
title: t('login.mobile.err_client_rejected_title', "{host} didn't accept the app", { host }),
|
||||||
|
detail: t('login.mobile.err_client_rejected_detail', 'The server refused Bulwark as a sign-in client. Ask your administrator to allow it, or sign in with an app password.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'TotpLoginError' && lower.includes('invalid')) {
|
if (name === 'TotpLoginError' && lower.includes('invalid')) {
|
||||||
return {
|
return {
|
||||||
title: t('login.mobile.err_bad_title', "That didn't work"),
|
title: t('login.mobile.err_bad_title', "That didn't work"),
|
||||||
|
|||||||
+358
-59
@@ -7,22 +7,57 @@
|
|||||||
// `POST {server}/api/auth`, which answers with a short-lived authorization
|
// `POST {server}/api/auth`, which answers with a short-lived authorization
|
||||||
// code that `POST {server}/auth/token` exchanges (with PKCE) for OAuth
|
// code that `POST {server}/auth/token` exchanges (with PKCE) for OAuth
|
||||||
// tokens. The webmail drives the same two steps server-side in
|
// tokens. The webmail drives the same two steps server-side in
|
||||||
// app/api/auth/totp-token-exchange/route.ts; a native client has no CORS
|
// lib/auth/stalwart-password-login.ts; a native client has no CORS
|
||||||
// constraint and talks to the mail server directly.
|
// constraint and talks to the mail server directly.
|
||||||
//
|
//
|
||||||
|
// The two server lines speak different dialects (webmail
|
||||||
|
// lib/auth/stalwart-login-request.ts):
|
||||||
|
// - 0.16 takes the authorization parameters as fields (`clientId`,
|
||||||
|
// `redirectUri`, `codeChallenge`, ...), accepts any client id and answers
|
||||||
|
// `{ type: "authenticated", client_code }`.
|
||||||
|
// - 1.0 takes the authorization request as a query string (`query`), wants
|
||||||
|
// `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".
|
||||||
|
// 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
|
||||||
// to embed the ~30 s code in every request.
|
// to embed the ~30 s code in every request.
|
||||||
|
|
||||||
import { secureFetch } from './client-cert';
|
import { mayFollowRedirect, secureFetch } from './client-cert';
|
||||||
import { sha256Hex } from './sha256';
|
import { sha256Hex } from './sha256';
|
||||||
import { base64UrlEncode, randomBytes } from './random';
|
import { base64UrlEncode, randomBytes, randomHex } from './random';
|
||||||
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
|
import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth';
|
||||||
import { NetworkError } from '../api/jmap-client';
|
import { NetworkError } from '../api/jmap-client';
|
||||||
|
|
||||||
// Stalwart's built-in OAuth accepts the webmail's public client id; using the
|
// Stalwart 0.16's built-in OAuth accepts any client id; the webmail's public
|
||||||
// same one keeps refresh behaviour identical to the browser-handoff bundle.
|
// one keeps refresh behaviour identical to the browser-handoff bundle.
|
||||||
export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect URI of the client the app registers at a Stalwart 1.0 that knows
|
||||||
|
* no client for it. 1.0 lets a public client register only a loopback URI or
|
||||||
|
* a private-use scheme with a dot in it (RFC 8252), which `bulwarkmobile://`
|
||||||
|
* is not; this one is the iOS bundle id. The password login never visits it
|
||||||
|
* (/api/auth hands the location back); the browser sign-in comes back on it,
|
||||||
|
* so the app registers the scheme too.
|
||||||
|
*/
|
||||||
|
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data scopes Stalwart 1.0 grants a client that isn't first-party. A
|
||||||
|
* token reaches only what these name (no scope: nothing but the session);
|
||||||
|
* 0.16 ignores them.
|
||||||
|
*/
|
||||||
|
export const STALWART_DATA_SCOPES = [
|
||||||
|
'urn:ietf:params:oauth:scope:mail',
|
||||||
|
'urn:ietf:params:oauth:scope:contacts',
|
||||||
|
'urn:ietf:params:oauth:scope:calendars',
|
||||||
|
'urn:ietf:params:oauth:scope:files',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STALWART_LOGIN_SCOPE = ['openid', 'offline_access', ...STALWART_DATA_SCOPES].join(' ');
|
||||||
|
|
||||||
export class LoginEndpointMissingError extends Error {
|
export class LoginEndpointMissingError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Structured login endpoint not available');
|
super('Structured login endpoint not available');
|
||||||
@@ -31,7 +66,10 @@ export class LoginEndpointMissingError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TotpLoginError extends Error {
|
export class TotpLoginError extends Error {
|
||||||
code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed';
|
// `client_rejected`: the server refused the app as an OAuth client (1.0:
|
||||||
|
// registration refused or the authorization request itself rejected),
|
||||||
|
// before it looked at the credentials.
|
||||||
|
code: 'invalid_credentials' | 'totp_required' | 'login_failed' | 'token_exchange_failed' | 'client_rejected';
|
||||||
constructor(code: TotpLoginError['code'], message?: string) {
|
constructor(code: TotpLoginError['code'], message?: string) {
|
||||||
super(message ?? code);
|
super(message ?? code);
|
||||||
this.name = 'TotpLoginError';
|
this.name = 'TotpLoginError';
|
||||||
@@ -55,18 +93,294 @@ export function generateCodeChallenge(verifier: string): string {
|
|||||||
return base64UrlEncode(hexToBytes(digest));
|
return base64UrlEncode(hexToBytes(digest));
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResult {
|
// ── Which client the app signs in as ───────────────────────
|
||||||
type?: string;
|
|
||||||
// The response keeps snake_case: only the LoginResponse variant *tags* are
|
export interface LoginClient {
|
||||||
// camelCased server-side, not the struct fields (the request fields are).
|
clientId: string;
|
||||||
client_code?: string;
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_CHECK_TIMEOUT_MS = 8_000;
|
||||||
|
const CLIENT_CACHE_MAX = 32;
|
||||||
|
// Per server. 1.0's registered client ids are stateless (sealed with the
|
||||||
|
// server's key): the same metadata registers to the same id again, so a lost
|
||||||
|
// cache costs a round trip, never a sign-in. The token bundle keeps the id it
|
||||||
|
// was issued to, which is what refresh and revocation present.
|
||||||
|
const loginClients = new Map<string, LoginClient>();
|
||||||
|
|
||||||
|
function trimBase(url: string): string {
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook. */
|
||||||
|
export function resetLoginClients(): void {
|
||||||
|
loginClients.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberClient(base: string, client: LoginClient): void {
|
||||||
|
if (!loginClients.has(base) && loginClients.size >= CLIENT_CACHE_MAX) {
|
||||||
|
const oldest = loginClients.keys().next().value;
|
||||||
|
if (oldest !== undefined) loginClients.delete(oldest);
|
||||||
|
}
|
||||||
|
loginClients.set(base, client);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } {
|
||||||
|
if (typeof AbortController === 'undefined') return { done: () => undefined };
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), ms);
|
||||||
|
return { signal: controller.signal, done: () => clearTimeout(timer) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password and the PKCE verifier must not follow a redirect to another
|
||||||
|
// host. The platform fetch follows redirects itself, so the check is on where
|
||||||
|
// the answer came from; the client check below runs first and is a plain GET,
|
||||||
|
// which keeps the password from being posted anywhere the server redirects.
|
||||||
|
function assertSameServer(requested: string, response: Response): void {
|
||||||
|
if (response.redirected && response.url && !mayFollowRedirect(requested, response.url)) {
|
||||||
|
throw new TotpLoginError('login_failed', `${requested} redirects to another host`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 7591 registration as a public client, which 1.0 allows without credentials. */
|
||||||
|
async function registerPublicClient(base: string): Promise<LoginClient> {
|
||||||
|
const url = `${base}/auth/register`;
|
||||||
|
const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await secureFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_name: 'Bulwark Mobile',
|
||||||
|
redirect_uris: [REGISTERED_REDIRECT_URI],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
grant_types: ['authorization_code', 'refresh_token'],
|
||||||
|
response_types: ['code'],
|
||||||
|
scope: STALWART_LOGIN_SCOPE,
|
||||||
|
}),
|
||||||
|
signal: t.signal,
|
||||||
|
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
|
} finally {
|
||||||
|
t.done();
|
||||||
|
}
|
||||||
|
assertSameServer(url, response);
|
||||||
|
const body = (await response.json().catch(() => null)) as { client_id?: unknown; error?: unknown } | null;
|
||||||
|
if (!response.ok || typeof body?.client_id !== 'string' || !body.client_id) {
|
||||||
|
const reason = typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`;
|
||||||
|
throw new TotpLoginError('client_rejected', `The server refused to register the app (${reason})`);
|
||||||
|
}
|
||||||
|
const client = { clientId: body.client_id, redirectUri: REGISTERED_REDIRECT_URI };
|
||||||
|
rememberClient(base, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OAuth client to sign in to `serverUrl` as. Stalwart 1.0's client check
|
||||||
|
* (GET /api/auth/client, which 0.16 doesn't have) says whether it knows the
|
||||||
|
* webmail's client id with the app's redirect URI; when it doesn't, the app
|
||||||
|
* registers its own public client. Any other server, and 0.16, gets
|
||||||
|
* `bulwark-webmail` as before. Unreachable: the default, uncached, and the
|
||||||
|
* request that needs it fails on its own.
|
||||||
|
*/
|
||||||
|
export async function resolveLoginClient(serverUrl: string): Promise<LoginClient> {
|
||||||
|
const base = trimBase(serverUrl);
|
||||||
|
const cached = loginClients.get(base);
|
||||||
|
if (cached) return cached;
|
||||||
|
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: DEFAULT_CLIENT_ID,
|
||||||
|
redirect_uri: HANDOFF_REDIRECT_URI,
|
||||||
|
state: 'probe',
|
||||||
|
code_challenge: 'A'.repeat(43),
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
const url = `${base}/api/auth/client?${query.toString()}`;
|
||||||
|
const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await secureFetch(url, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
signal: t.signal,
|
||||||
|
timeoutMs: CLIENT_CHECK_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
} finally {
|
||||||
|
t.done();
|
||||||
|
}
|
||||||
|
if (!response || typeof response.status !== 'number') return fallback;
|
||||||
|
assertSameServer(url, response);
|
||||||
|
// 0.16 and servers that aren't Stalwart: no such endpoint.
|
||||||
|
if (!response.ok) {
|
||||||
|
rememberClient(base, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||||
|
if (!body || typeof body !== 'object') {
|
||||||
|
rememberClient(base, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
// `invalid_client` (unknown id) or a redirect URI the admin's client
|
||||||
|
// doesn't list: either way the webmail's client isn't the app's to use.
|
||||||
|
if (typeof body.error === 'string' && body.error) return registerPublicClient(base);
|
||||||
|
rememberClient(base, fallback);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /api/auth request and answer ───────────────────────────
|
||||||
|
|
||||||
|
export interface StalwartLoginRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
totp?: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
/** S256 PKCE challenge. */
|
||||||
|
codeChallenge: string;
|
||||||
|
/** Echoed back in 1.0's redirect location. */
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stalwartLoginBody(req: StalwartLoginRequest): Record<string, unknown> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: req.clientId,
|
||||||
|
redirect_uri: req.redirectUri,
|
||||||
|
state: req.state,
|
||||||
|
scope: STALWART_LOGIN_SCOPE,
|
||||||
|
code_challenge: req.codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'authCode',
|
||||||
|
accountName: req.username,
|
||||||
|
accountSecret: req.password,
|
||||||
|
...(req.totp ? { mfaToken: req.totp } : {}),
|
||||||
|
// Stalwart 0.16
|
||||||
|
clientId: req.clientId,
|
||||||
|
redirectUri: req.redirectUri,
|
||||||
|
codeChallenge: req.codeChallenge,
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
// Stalwart 1.0. The user typed the password into the app's own sign-in
|
||||||
|
// form, which is the consent the server would otherwise ask for.
|
||||||
|
query: query.toString(),
|
||||||
|
consent: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StalwartLoginOutcome =
|
||||||
|
| { kind: 'code'; code: string }
|
||||||
|
| { kind: 'mfa_required' }
|
||||||
|
| { kind: 'invalid_credentials' }
|
||||||
|
/**
|
||||||
|
* The server refused the authorization request itself (1.0: unknown
|
||||||
|
* client, redirect URI not registered, scope not allowed), before looking
|
||||||
|
* at the credentials.
|
||||||
|
*/
|
||||||
|
| { kind: 'rejected'; error: string; description?: string };
|
||||||
|
|
||||||
|
function locationParams(location: string): URLSearchParams | null {
|
||||||
|
// A private-use scheme isn't something every URL parser takes apart, so
|
||||||
|
// read the query by hand.
|
||||||
|
const q = location.indexOf('?');
|
||||||
|
if (q < 0) return null;
|
||||||
|
const h = location.indexOf('#', q);
|
||||||
|
return new URLSearchParams(location.slice(q + 1, h >= 0 ? h : undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the JSON answer of POST /api/auth (HTTP 200) of either server line. */
|
||||||
|
export function readStalwartLoginResponse(body: unknown, state: string): StalwartLoginOutcome {
|
||||||
|
const res = (body && typeof body === 'object' ? body : {}) as Record<string, unknown>;
|
||||||
|
switch (res.type) {
|
||||||
|
case 'authenticated': {
|
||||||
|
// The response keeps snake_case: only the LoginResponse variant tags
|
||||||
|
// are camelCased server-side, not the struct fields.
|
||||||
|
if (typeof res.client_code === 'string' && res.client_code) {
|
||||||
|
return { kind: 'code', code: res.client_code };
|
||||||
|
}
|
||||||
|
const params = typeof res.location === 'string' ? locationParams(res.location) : null;
|
||||||
|
const code = params?.get('code');
|
||||||
|
if (!code) return { kind: 'rejected', error: 'missing_code' };
|
||||||
|
if (params?.get('state') !== state) return { kind: 'rejected', error: 'state_mismatch' };
|
||||||
|
return { kind: 'code', code };
|
||||||
|
}
|
||||||
|
case 'mfaRequired':
|
||||||
|
return { kind: 'mfa_required' };
|
||||||
|
case 'invalid':
|
||||||
|
return {
|
||||||
|
kind: 'rejected',
|
||||||
|
error: typeof res.error === 'string' ? res.error : 'invalid_request',
|
||||||
|
...(typeof res.description === 'string' ? { description: res.description } : {}),
|
||||||
|
};
|
||||||
|
case 'redirect': {
|
||||||
|
// 1.0 sends errors it may show the client to the redirect URI.
|
||||||
|
const params = typeof res.location === 'string' ? locationParams(res.location) : null;
|
||||||
|
const description = params?.get('error_description');
|
||||||
|
return {
|
||||||
|
kind: 'rejected',
|
||||||
|
error: params?.get('error') || 'access_denied',
|
||||||
|
...(description ? { description } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return { kind: 'invalid_credentials' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestLoginCode(
|
||||||
|
base: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
totp: string | undefined,
|
||||||
|
client: LoginClient,
|
||||||
|
challenge: string,
|
||||||
|
): Promise<StalwartLoginOutcome> {
|
||||||
|
const state = randomHex(16);
|
||||||
|
const url = `${base}/api/auth`;
|
||||||
|
let loginResponse: Response;
|
||||||
|
try {
|
||||||
|
loginResponse = await secureFetch(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,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
|
}
|
||||||
|
assertSameServer(url, loginResponse);
|
||||||
|
|
||||||
|
if (loginResponse.status === 404) throw new LoginEndpointMissingError();
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`);
|
||||||
|
}
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await loginResponse.json();
|
||||||
|
} catch {
|
||||||
|
throw new TotpLoginError('login_failed', 'Login response was not JSON');
|
||||||
|
}
|
||||||
|
return readStalwartLoginResponse(body, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
* endpoint (404) so the caller can fall back to legacy Basic auth,
|
* endpoint (404) so the caller can fall back to legacy Basic auth,
|
||||||
* `TotpLoginError` for rejected credentials, `NetworkError` when unreachable.
|
* `TotpLoginError` for rejected credentials or a refused client,
|
||||||
|
* `NetworkError` when unreachable.
|
||||||
*/
|
*/
|
||||||
export async function exchangePasswordForTokens(
|
export async function exchangePasswordForTokens(
|
||||||
serverUrl: string,
|
serverUrl: string,
|
||||||
@@ -75,68 +389,52 @@ export async function exchangePasswordForTokens(
|
|||||||
totp?: string,
|
totp?: string,
|
||||||
opts?: { clientId?: string; redirectUri?: string },
|
opts?: { clientId?: string; redirectUri?: string },
|
||||||
): Promise<OAuthTokens> {
|
): Promise<OAuthTokens> {
|
||||||
const base = serverUrl.replace(/\/+$/, '');
|
const base = trimBase(serverUrl);
|
||||||
const clientId = opts?.clientId ?? DEFAULT_CLIENT_ID;
|
|
||||||
const redirectUri = opts?.redirectUri ?? HANDOFF_REDIRECT_URI;
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
const challenge = generateCodeChallenge(verifier);
|
const challenge = generateCodeChallenge(verifier);
|
||||||
|
|
||||||
let loginResponse: Response;
|
let client: LoginClient = opts?.clientId
|
||||||
try {
|
? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI }
|
||||||
loginResponse = await secureFetch(`${base}/api/auth`, {
|
: await resolveLoginClient(base);
|
||||||
method: 'POST',
|
let outcome = await requestLoginCode(base, username, password, totp, client, challenge);
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
// A remembered client the server no longer knows (another server now
|
||||||
body: JSON.stringify({
|
// answers at the address, or its key changed): ask again, once. 1.0 checks
|
||||||
type: 'authCode',
|
// the client before the credentials, so this costs no failed sign-in.
|
||||||
accountName: username,
|
if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId && loginClients.has(base)) {
|
||||||
accountSecret: password,
|
loginClients.delete(base);
|
||||||
...(totp ? { mfaToken: totp } : {}),
|
const fresh = await resolveLoginClient(base);
|
||||||
clientId,
|
if (fresh.clientId !== client.clientId) {
|
||||||
redirectUri,
|
client = fresh;
|
||||||
codeChallenge: challenge,
|
outcome = await requestLoginCode(base, username, password, totp, client, challenge);
|
||||||
codeChallengeMethod: 'S256',
|
}
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loginResponse.status === 404) throw new LoginEndpointMissingError();
|
switch (outcome.kind) {
|
||||||
if (!loginResponse.ok) {
|
case 'code':
|
||||||
throw new TotpLoginError('login_failed', `Login failed: ${loginResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let login: LoginResult;
|
|
||||||
try {
|
|
||||||
login = (await loginResponse.json()) as LoginResult;
|
|
||||||
} catch {
|
|
||||||
throw new TotpLoginError('login_failed', 'Login response was not JSON');
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (login.type) {
|
|
||||||
case 'authenticated':
|
|
||||||
break;
|
break;
|
||||||
case 'mfaRequired':
|
case 'mfa_required':
|
||||||
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
|
throw new TotpLoginError('totp_required', 'TOTP_REQUIRED');
|
||||||
case 'failure':
|
case 'invalid_credentials':
|
||||||
default:
|
|
||||||
throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code');
|
throw new TotpLoginError('invalid_credentials', 'Invalid username, password or code');
|
||||||
}
|
case 'rejected':
|
||||||
if (!login.client_code) {
|
throw new TotpLoginError(
|
||||||
throw new TotpLoginError('login_failed', 'Login response missing authorization code');
|
outcome.error === 'missing_code' || outcome.error === 'state_mismatch' ? 'login_failed' : 'client_rejected',
|
||||||
|
`Sign-in refused: ${[outcome.error, outcome.description].filter(Boolean).join(': ')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenParams = new URLSearchParams({
|
const tokenParams = new URLSearchParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code: login.client_code,
|
code: outcome.code,
|
||||||
client_id: clientId,
|
client_id: client.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: client.redirectUri,
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tokenUrl = `${base}/auth/token`;
|
||||||
let tokenResponse: Response;
|
let tokenResponse: Response;
|
||||||
try {
|
try {
|
||||||
tokenResponse = await secureFetch(`${base}/auth/token`, {
|
tokenResponse = await secureFetch(tokenUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
body: tokenParams.toString(),
|
body: tokenParams.toString(),
|
||||||
@@ -144,6 +442,7 @@ export async function exchangePasswordForTokens(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable');
|
||||||
}
|
}
|
||||||
|
assertSameServer(tokenUrl, tokenResponse);
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
throw new TotpLoginError('token_exchange_failed', `Token exchange failed: ${tokenResponse.status}`);
|
throw new TotpLoginError('token_exchange_failed', `Token exchange failed: ${tokenResponse.status}`);
|
||||||
}
|
}
|
||||||
@@ -160,8 +459,8 @@ export async function exchangePasswordForTokens(
|
|||||||
accessToken: tokens.access_token,
|
accessToken: tokens.access_token,
|
||||||
refreshToken: tokens.refresh_token,
|
refreshToken: tokens.refresh_token,
|
||||||
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
||||||
tokenEndpoint: `${base}/auth/token`,
|
tokenEndpoint: tokenUrl,
|
||||||
clientId,
|
clientId: client.clientId,
|
||||||
source: 'totp',
|
source: 'totp',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user