fix: sign in with password and TOTP on Stalwart 1.0

This commit is contained in:
Linus Rath
2026-10-26 20:25:42 +01:00
parent d9f1035904
commit 8860dde005
8 changed files with 588 additions and 60 deletions
+3 -2
View File
@@ -14,6 +14,7 @@ import {
getMetadata,
getRequiredConfig,
getTokenEndpoint,
defaultClientOptions,
DEFAULT_CLIENT_ID,
} from '@/lib/oauth/token-exchange';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
@@ -50,7 +51,7 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada
const params = buildOAuthParams({
token,
token_type_hint: 'refresh_token',
}, serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
}, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID })));
try {
// Never follow a redirect with the refresh token and client secret in
@@ -215,7 +216,7 @@ export async function PUT(request: NextRequest) {
const params = buildOAuthParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}, serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
}, serverId, await defaultClientOptions(serverId));
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
+5 -3
View File
@@ -46,10 +46,10 @@ async function attemptLogin(
// server entry has its own oauth block configured.
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
const entry = findServerById(serverList, serverId);
const clientId = entry?.oauth?.clientId
const configuredClientId = entry?.oauth?.clientId
|| configManager.get<string>('oauthClientId', '')
|| process.env.OAUTH_CLIENT_ID
|| DEFAULT_CLIENT_ID;
|| process.env.OAUTH_CLIENT_ID;
const clientId = configuredClientId || DEFAULT_CLIENT_ID;
const clientSecret = entry?.oauth?.clientSecret
|| configManager.get<string>('oauthClientSecret', '')
|| process.env.OAUTH_CLIENT_SECRET
@@ -75,6 +75,8 @@ async function attemptLogin(
redirectUri,
trusted: upstreamTrusted,
clientIp,
// Stalwart 1.0 knows no client by the default name.
registerUnknownClient: !configuredClientId,
});
// Only a rejected password spends the budget. `totp_required` means the
// password was right and the code is still missing, which every TOTP login
+25
View File
@@ -24,12 +24,14 @@ const buildOAuthParams = vi.fn();
const getMetadata = vi.fn();
const getRequiredConfig = vi.fn();
const exchangeCodeForTokens = vi.fn();
const defaultClientOptions = vi.fn(async (_serverId?: unknown) => ({ fallbackClientId: 'bulwark-webmail' }));
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args),
buildOAuthParams: (...args: unknown[]) => buildOAuthParams(...args),
getMetadata: (...args: unknown[]) => getMetadata(...args),
getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args),
getTokenEndpoint: (...args: unknown[]) => getTokenEndpoint(...args),
defaultClientOptions: (serverId: unknown) => defaultClientOptions(serverId),
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
@@ -281,6 +283,29 @@ describe('oauth token route - access token cache (#552)', () => {
);
});
it('refreshes with the client the TOTP login registered on Stalwart 1.0', async () => {
// 1.0 knows no client named bulwark-webmail; the login signed in with a
// client it registered, and a refresh naming another client is refused.
defaultClientOptions.mockResolvedValueOnce({ fallbackClientId: 'swc1.registered' });
cookieStore.set('jmap_rt', 'totp-minted-refresh-token');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ access_token: 'fresh-token', expires_in: 1800, refresh_token: 'rotated' }),
});
const { status } = await callPut({ force: 'true' });
expect(status).toBe(200);
expect(defaultClientOptions).toHaveBeenCalledWith(null);
expect(buildOAuthParams).toHaveBeenCalledWith(
{ grant_type: 'refresh_token', refresh_token: 'totp-minted-refresh-token' },
null,
{ fallbackClientId: 'swc1.registered' },
);
// 1.0 rotates refresh tokens; the old one is dead once the new one is used.
expect(cookieStore.get('jmap_rt')?.value).toBe('rotated');
});
it('does not serve a cached token once the refresh token is gone', async () => {
seedCachedToken('orphan-token', 1500);
@@ -0,0 +1,190 @@
// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/logger', () => ({
logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} },
}));
import {
STALWART_LOOPBACK_REDIRECT_URI,
resetStalwartDefaultClients,
stalwartDefaultClientId,
stalwartPasswordLogin,
} from '@/lib/auth/stalwart-password-login';
// Stalwart 1.0 changed its structured login: the authorization request goes in
// as a query string (`query`), the answer carries the redirect location with
// the code instead of `client_code`, and only clients it knows may sign in.
// The answers below are the ones both servers gave live (0.16.27, 1.0.0).
const SERVER = 'https://mail.example.org';
const REDIRECT = 'https://webmail.example/en/auth/callback';
interface Call { url: string; method: string; body: string }
let calls: Call[];
type Handler = (call: Call) => Response;
function stub(handler: Handler) {
vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: RequestInit) => {
const call = { url: String(input), method: init?.method ?? 'GET', body: String(init?.body ?? '') };
calls.push(call);
return handler(call);
}));
}
/** Stalwart 1.0's /api/auth: knows `known` clients, answers with the redirect location. */
function v1(known: string[], registered = 'swc1.registered'): Handler {
return (call) => {
if (call.url === `${SERVER}/api/auth`) {
const body = JSON.parse(call.body);
if (typeof body.query !== 'string') {
return Response.json({ type: 'about:blank', status: 400, title: 'Invalid parameters', detail: 'JSON deserialization failed' }, { status: 400 });
}
const q = new URLSearchParams(body.query);
if (!known.includes(q.get('client_id') ?? '')) {
return Response.json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
}
if (body.consent !== true) {
return Response.json({ type: 'redirect', location: `${q.get('redirect_uri')}?error=access_denied&state=${q.get('state')}` });
}
return Response.json({
type: 'authenticated',
location: `${q.get('redirect_uri')}?code=v1-code&state=${encodeURIComponent(q.get('state') ?? '')}&iss=${encodeURIComponent(SERVER)}`,
});
}
if (call.url === `${SERVER}/auth/register`) {
return Response.json({ client_id: registered, redirect_uris: JSON.parse(call.body).redirect_uris }, { status: 201 });
}
if (call.url.startsWith(`${SERVER}/api/auth/client?`)) {
const q = new URL(call.url).searchParams;
return known.includes(q.get('client_id') ?? '')
? Response.json({ name: null, scopes: [] })
: Response.json({ error: 'invalid_client', description: 'Unknown client id', redirect: false });
}
if (call.url === `${SERVER}/auth/token`) {
const p = new URLSearchParams(call.body);
return p.get('code') === 'v1-code'
? Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 })
: Response.json({ error: 'invalid_grant' }, { status: 400 });
}
return new Response('not found', { status: 404 });
};
}
/** Stalwart 0.16's /api/auth: any client id, answers with client_code. */
const v016: Handler = (call) => {
if (call.url === `${SERVER}/api/auth`) return Response.json({ type: 'authenticated', client_code: 'old-code', iss: SERVER });
if (call.url === `${SERVER}/auth/token`) return Response.json({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
return Response.json({ type: 'about:blank', status: 404, title: 'Not Found' }, { status: 404 });
};
function login(clientId: string, registerUnknownClient = false) {
return stalwartPasswordLogin({
serverUrl: SERVER,
username: 'alice@example.org',
password: 'hunter2',
totp: '123456',
clientId,
redirectUri: REDIRECT,
trusted: true,
registerUnknownClient,
});
}
const tokenParams = () => new URLSearchParams(calls.find((c) => c.url === `${SERVER}/auth/token`)!.body);
beforeEach(() => {
calls = [];
resetStalwartDefaultClients();
});
describe('stalwartPasswordLogin on Stalwart 1.0', () => {
it('signs in with a client the server knows, reading the code from the location', async () => {
stub(v1(['webmail']));
const result = await login('webmail');
expect(result).toMatchObject({ ok: true, clientId: 'webmail' });
expect(tokenParams().get('code')).toBe('v1-code');
expect(tokenParams().get('redirect_uri')).toBe(REDIRECT);
const body = JSON.parse(calls[0].body);
expect(new URLSearchParams(body.query).get('scope')).toContain('urn:ietf:params:oauth:scope:mail');
});
it('registers its own client when the default one is unknown, then signs in with it', async () => {
stub(v1(['swc1.registered']));
const result = await login('bulwark-webmail', true);
expect(result).toMatchObject({ ok: true, clientId: 'swc1.registered' });
const register = JSON.parse(calls.find((c) => c.url === `${SERVER}/auth/register`)!.body);
expect(register.redirect_uris).toEqual([STALWART_LOOPBACK_REDIRECT_URI]);
expect(register.token_endpoint_auth_method).toBe('none');
expect(tokenParams().get('client_id')).toBe('swc1.registered');
expect(tokenParams().get('redirect_uri')).toBe(STALWART_LOOPBACK_REDIRECT_URI);
expect(tokenParams().get('client_secret')).toBeNull();
});
it('reuses the registered client for the next sign-in', async () => {
stub(v1(['swc1.registered']));
await login('bulwark-webmail', true);
calls = [];
await login('bulwark-webmail', true);
expect(calls.map((c) => c.url)).toEqual([`${SERVER}/api/auth`, `${SERVER}/auth/token`]);
});
it('does not register for an admin-configured client', async () => {
stub(v1([]));
const result = await login('configured-client');
expect(result).toMatchObject({ ok: false, error: 'login_rejected', detail: 'invalid_client: Unknown client id' });
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
});
it('reports an authorization error the server sends to the redirect URI', async () => {
stub((call) => call.url === `${SERVER}/api/auth`
? Response.json({ type: 'redirect', location: `${REDIRECT}?error=invalid_scope&error_description=The+requested+scope+is+not+allowed&state=x` })
: v1(['webmail'])(call));
const result = await login('webmail');
expect(result).toMatchObject({ ok: false, error: 'login_rejected', detail: 'invalid_scope: The requested scope is not allowed' });
});
it('keeps the credential verdicts', async () => {
stub(() => Response.json({ type: 'mfaRequired' }));
expect(await login('webmail')).toMatchObject({ ok: false, error: 'totp_required' });
stub(() => Response.json({ type: 'failure' }));
expect(await login('webmail')).toMatchObject({ ok: false, error: 'invalid_credentials' });
});
});
describe('stalwartPasswordLogin on Stalwart 0.16', () => {
it('signs in with the default client and the field-style request', async () => {
stub(v016);
const result = await login('bulwark-webmail', true);
expect(result).toMatchObject({ ok: true, clientId: 'bulwark-webmail' });
const body = JSON.parse(calls[0].body);
expect(body).toMatchObject({ clientId: 'bulwark-webmail', redirectUri: REDIRECT, codeChallengeMethod: 'S256', mfaToken: '123456' });
expect(tokenParams().get('code')).toBe('old-code');
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
});
});
describe('stalwartDefaultClientId', () => {
it('keeps the default client on 0.16, which has no client check', async () => {
stub(v016);
expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('bulwark-webmail');
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
});
it('names the registered client on 1.0, where the default one is unknown', async () => {
stub(v1(['swc1.registered']));
expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('swc1.registered');
});
it('keeps a default client the admin registered on 1.0', async () => {
stub(v1(['bulwark-webmail']));
expect(await stalwartDefaultClientId(SERVER, 'bulwark-webmail', true)).toBe('bulwark-webmail');
});
});
+8 -6
View File
@@ -70,14 +70,14 @@ export interface PasswordGrantOptions {
now?: Date;
}
function configuredClient(serverId: string | null): { clientId: string; clientSecret: string } {
let clientId = DEFAULT_CLIENT_ID;
function configuredClient(serverId: string | null): { clientId: string; clientSecret: string; isDefault: boolean } {
let clientId = '';
try {
clientId = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).clientId;
clientId = getRequiredConfig(serverId).clientId;
} catch {
// No server configured (custom endpoints only): the default client.
// None configured (or custom endpoints only): the default client.
}
return { clientId, clientSecret: getClientSecret(serverId) };
return { clientId: clientId || DEFAULT_CLIENT_ID, clientSecret: getClientSecret(serverId), isDefault: !clientId };
}
function oauthGrant(
@@ -158,9 +158,11 @@ export async function mintGrantWithPassword(opts: PasswordGrantOptions): Promise
clientSecret: configured.clientSecret,
redirectUri: opts.webmailRedirectUri,
trusted: opts.trusted,
// As the password+TOTP login: Stalwart 1.0 knows no default client.
registerUnknownClient: configured.isDefault,
});
if (second.ok) {
return { ok: true, grant: oauthGrant(opts, second, configured.clientId, configured.clientSecret !== '') };
return { ok: true, grant: oauthGrant(opts, second, second.clientId, configured.clientSecret !== '' && second.clientId === configured.clientId) };
}
const secondVerdict = verdict(second);
if (secondVerdict) return secondVerdict;
+124
View File
@@ -0,0 +1,124 @@
/**
* Request and answer of Stalwart's structured login endpoint (POST /api/auth),
* shared by the server-side password+TOTP login and the Lite build. Kept free
* of server imports so the browser bundle can use it.
*
* The two server lines speak different dialects:
* - 0.16 takes the authorization parameters as fields (`clientId`,
* `redirectUri`, `codeChallenge`, ...) and answers
* `{ type: "authenticated", client_code }`.
* - 1.0 takes the authorization request as a query string (`query`), wants
* `consent: true` for any client it doesn't host itself, and answers
* `{ type: "authenticated", location }`, the redirect URI carrying the code.
* It refuses a body without `query` ("JSON deserialization failed").
* Neither rejects fields it doesn't know, so one body serves both.
*/
/**
* The data scopes Stalwart 1.0 grants per client. A token issued to a client
* Stalwart doesn't treat as first-party reaches only what these name (no
* scope: nothing but the session); 0.16 ignores them.
*/
export const STALWART_LOGIN_SCOPE = [
'openid',
'offline_access',
'urn:ietf:params:oauth:scope:mail',
'urn:ietf:params:oauth:scope:contacts',
'urn:ietf:params:oauth:scope:calendars',
'urn:ietf:params:oauth:scope:files',
].join(' ');
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 their password into this client'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 {
try {
return new URL(location).searchParams;
} catch {
return null;
}
}
/** 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': {
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' };
}
}
+216 -46
View File
@@ -1,7 +1,12 @@
import { logger } from '@/lib/logger';
import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@/lib/oauth/pkce';
import { forwardedClientHeaders } from '@/lib/auth/client-address';
import {
STALWART_LOGIN_SCOPE,
readStalwartLoginResponse,
stalwartLoginBody,
} from '@/lib/auth/stalwart-login-request';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens at Stalwart.
@@ -18,15 +23,10 @@ import { forwardedClientHeaders } from '@/lib/auth/client-address';
*
* Used by the password+TOTP login (/api/auth/totp-token-exchange) and by the
* password step-up that mints a paired phone's own grant (/api/auth/pair).
* The request and answer differ between Stalwart 0.16 and 1.0, see
* lib/auth/stalwart-login-request.ts.
*/
interface LoginResult {
type?: string;
// The response keeps snake_case: only the LoginResponse variant *tags* are
// camelCased server-side, not the struct fields (the request fields are).
client_code?: string;
}
export interface StalwartPasswordLoginOptions {
serverUrl: string;
username: string;
@@ -48,6 +48,12 @@ export interface StalwartPasswordLoginOptions {
* client rather than against the webmail (see lib/auth/client-address.ts).
*/
clientIp?: string;
/**
* `clientId` is the built-in default, not one the admin registered. Stalwart
* 1.0 knows no such client, so the login registers its own (see
* {@link stalwartDefaultClient}) and signs in with that.
*/
registerUnknownClient?: boolean;
}
export interface StalwartTokens {
@@ -57,13 +63,15 @@ export interface StalwartTokens {
}
export type StalwartPasswordLoginResult =
| { ok: true; tokens: StalwartTokens; tokenEndpoint: string }
/** `clientId`: the client the tokens belong to; refreshes must present it. */
| { ok: true; tokens: StalwartTokens; tokenEndpoint: string; clientId: string }
| {
ok: false;
/**
* - `login_endpoint_missing`: no structured login (404) - older server.
* - `login_rejected`: /api/auth refused the request itself (HTTP error):
* an unregistered client or redirect, bad PKCE; Stalwart does not say.
* - `login_rejected`: /api/auth refused the request itself: an
* unregistered client or redirect, a scope the client may not have,
* bad PKCE (0.16 answers an HTTP error and does not say which).
* - `totp_required` / `invalid_credentials`: the credential verdict.
*/
error:
@@ -120,35 +128,163 @@ async function fetchConfiguredServer(input: string, init: RequestInit): Promise<
return fetch(next, { ...init, redirect: 'error' });
}
export async function stalwartPasswordLogin(
opts: StalwartPasswordLoginOptions,
): Promise<StalwartPasswordLoginResult> {
const base = trimUrl(opts.serverUrl);
const upstreamFetch: typeof fetch = opts.trusted
function upstreamFetchFor(trusted: boolean): typeof fetch {
return trusted
? ((input, init) => fetchConfiguredServer(String(input), init ?? {}))
: ((input, init) => fetchPublicUrl(String(input), init as Parameters<typeof fetchPublicUrl>[1]) as unknown as Promise<Response>);
}
// PKCE proves the token exchange originates from the same client that
// initiated the login, so no client secret is required for public clients.
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
/**
* Redirect URI of the client the login registers at Stalwart 1.0 (below). No
* browser is ever sent there: /api/auth hands the redirect location back in
* its answer and the code is read from it. 1.0 lets a public client register
* only a loopback or private-use redirect URI.
*/
export const STALWART_LOOPBACK_REDIRECT_URI = 'http://127.0.0.1/bulwark-webmail/callback';
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
interface LoginClient {
clientId: string;
/** Set for the registered client, which may only use this one. */
redirectUri?: string;
}
const DEFAULT_CLIENT_TTL_MS = 10 * 60 * 1000;
// Sign-out waits for the client check, so it must not hang.
const CLIENT_CHECK_TIMEOUT_MS = 5000;
const DEFAULT_CLIENT_CACHE_MAX = 64;
const defaultClients = new Map<string, { client: LoginClient; expiresAt: number }>();
function cachedDefaultClient(base: string): LoginClient | null {
const entry = defaultClients.get(base);
if (!entry) return null;
if (entry.expiresAt > Date.now()) return entry.client;
defaultClients.delete(base);
return null;
}
function rememberDefaultClient(base: string, client: LoginClient): void {
if (!defaultClients.has(base) && defaultClients.size >= DEFAULT_CLIENT_CACHE_MAX) {
const oldest = defaultClients.keys().next().value;
if (oldest !== undefined) defaultClients.delete(oldest);
}
defaultClients.set(base, { client, expiresAt: Date.now() + DEFAULT_CLIENT_TTL_MS });
}
/** Test hook. */
export function resetStalwartDefaultClients(): void {
defaultClients.clear();
}
/**
* Registers Bulwark as a public client (RFC 7591), which Stalwart 1.0 allows
* without credentials. Its client ids are stateless, sealed with the server's
* key: the same metadata registers to the same id again, so nothing has to be
* stored or cleaned up. Tokens for it reach mail, contacts, calendars and
* files (the scopes it registers), but not the account's Stalwart settings
* (password, TOTP, app passwords): only a first-party client gets those.
*/
async function registerPublicClient(base: string, upstreamFetch: typeof fetch): Promise<LoginClient | null> {
try {
const response = await upstreamFetch(`${base}/auth/register`, {
method: 'POST',
signal: AbortSignal.timeout(CLIENT_CHECK_TIMEOUT_MS),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_name: 'Bulwark Webmail',
redirect_uris: [STALWART_LOOPBACK_REDIRECT_URI],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: STALWART_LOGIN_SCOPE,
}),
});
if (!response.ok) {
logger.warn('Stalwart password login: client registration refused', { status: response.status });
return null;
}
const body = (await response.json()) as { client_id?: unknown };
if (typeof body.client_id !== 'string' || !body.client_id) return null;
const client = { clientId: body.client_id, redirectUri: STALWART_LOOPBACK_REDIRECT_URI };
rememberDefaultClient(base, client);
return client;
} catch (err) {
logger.warn('Stalwart password login: client registration failed', { error: err instanceof Error ? err.message : String(err) });
return null;
}
}
/**
* The client that tokens from a login with the built-in default client id
* belong to. Stalwart 0.16 accepts any client id; 1.0 only one it knows, so
* there the login registers its own (see {@link stalwartPasswordLogin}).
* Refreshing and revoking those tokens must present the same id: this asks
* 1.0's client check (GET /api/auth/client, which 0.16 doesn't have) whether
* the server knows `defaultClientId`, and registers when it doesn't.
*/
export async function stalwartDefaultClientId(
serverUrl: string,
defaultClientId: string,
trusted: boolean,
): Promise<string> {
const base = trimUrl(serverUrl);
const cached = cachedDefaultClient(base);
if (cached) return cached.clientId;
const upstreamFetch = upstreamFetchFor(trusted);
const query = new URLSearchParams({
response_type: 'code',
client_id: defaultClientId,
redirect_uri: STALWART_LOOPBACK_REDIRECT_URI,
state: 'probe',
code_challenge: 'A'.repeat(43),
code_challenge_method: 'S256',
});
let unknown: boolean;
try {
const response = await upstreamFetch(`${base}/api/auth/client?${query}`, {
method: 'GET',
signal: AbortSignal.timeout(CLIENT_CHECK_TIMEOUT_MS),
});
const body = response.ok ? ((await response.json().catch(() => null)) as { error?: unknown } | null) : null;
unknown = body?.error === 'invalid_client';
} catch {
// Unreachable: the request that asked fails on its own.
return defaultClientId;
}
if (unknown) {
const registered = await registerPublicClient(base, upstreamFetch);
return registered?.clientId ?? defaultClientId;
}
rememberDefaultClient(base, { clientId: defaultClientId });
return defaultClientId;
}
type LoginStep =
| { ok: true; code: string }
| (Extract<StalwartPasswordLoginResult, { ok: false }> & { rejection?: string });
/** POST /api/auth: the credentials for an authorization code. */
async function requestLoginCode(
base: string,
upstreamFetch: typeof fetch,
opts: StalwartPasswordLoginOptions,
client: LoginClient,
challenge: string,
): Promise<LoginStep> {
const state = generateState();
let body: unknown;
try {
const loginResponse = await upstreamFetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...forwardedClientHeaders(opts.clientIp) },
body: JSON.stringify({
type: 'authCode',
accountName: opts.username,
accountSecret: opts.password,
...(opts.totp ? { mfaToken: opts.totp } : {}),
clientId: opts.clientId,
redirectUri: opts.redirectUri,
body: JSON.stringify(stalwartLoginBody({
username: opts.username,
password: opts.password,
totp: opts.totp,
clientId: client.clientId,
redirectUri: client.redirectUri ?? opts.redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
state,
})),
});
if (!loginResponse.ok) {
@@ -162,7 +298,7 @@ export async function stalwartPasswordLogin(
};
}
login = await loginResponse.json();
body = await loginResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('Stalwart password login: rejected non-public server URL at connect time');
@@ -172,34 +308,67 @@ export async function stalwartPasswordLogin(
return { ok: false, error: 'login_unreachable' };
}
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
const outcome = readStalwartLoginResponse(body, state);
switch (outcome.kind) {
case 'code':
return { ok: true, code: outcome.code };
case 'mfa_required':
return { ok: false, error: 'totp_required' };
case 'failure':
default:
case 'invalid_credentials':
return { ok: false, error: 'invalid_credentials' };
case 'rejected':
logger.warn('Stalwart password login: authorization refused', { error: outcome.error });
return {
ok: false,
error: 'login_rejected',
rejection: outcome.error,
...(opts.trusted ? { detail: [outcome.error, outcome.description].filter(Boolean).join(': ') } : {}),
};
}
}
if (!login.client_code) {
logger.warn('Stalwart password login: authenticated response missing client_code');
return { ok: false, error: 'login_failed' };
export async function stalwartPasswordLogin(
opts: StalwartPasswordLoginOptions,
): Promise<StalwartPasswordLoginResult> {
const base = trimUrl(opts.serverUrl);
const upstreamFetch = upstreamFetchFor(opts.trusted);
// PKCE proves the token exchange originates from the same client that
// initiated the login, so no client secret is required for public clients.
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
// Step 1: structured login with a separate MFA token.
let client: LoginClient = (opts.registerUnknownClient && cachedDefaultClient(base)) || { clientId: opts.clientId };
let login = await requestLoginCode(base, upstreamFetch, opts, client, challenge);
// Stalwart 1.0 checks the client before the credentials, so the first
// attempt costs no failed sign-in.
if (!login.ok && login.rejection === 'invalid_client' && opts.registerUnknownClient && !client.redirectUri) {
const registered = await registerPublicClient(base, upstreamFetch);
if (registered) {
client = registered;
login = await requestLoginCode(base, upstreamFetch, opts, client, challenge);
}
}
if (!login.ok) {
const { rejection: _rejection, ...failure } = login;
return failure;
}
// Step 2: exchange the authorization code for tokens.
const tokenEndpoint = `${base}/auth/token`;
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: opts.clientId,
redirect_uri: opts.redirectUri,
code: login.code,
client_id: client.clientId,
redirect_uri: client.redirectUri ?? opts.redirectUri,
code_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
// The secret is the admin's, for the admin's server: a user-chosen server
// (allowCustomJmapEndpoint) never receives it.
if (opts.clientSecret && opts.trusted) tokenParams.set('client_secret', opts.clientSecret);
// (allowCustomJmapEndpoint) never receives it. The client registered above
// has none.
if (opts.clientSecret && opts.trusted && !client.redirectUri) tokenParams.set('client_secret', opts.clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
@@ -233,5 +402,6 @@ export async function stalwartPasswordLogin(
ok: true,
tokens: { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
tokenEndpoint,
clientId: client.clientId,
};
}
+17 -3
View File
@@ -5,12 +5,14 @@ import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
// Fallback OAuth client id used when no client is configured. The password+TOTP
// login route mints tokens against the mail server's built-in OAuth with this
// id (Stalwart accepts any client id unless `require_client_registration` is
// enabled), so refreshing/revoking those tokens must fall back to the same id
// instead of failing on the missing OAUTH_CLIENT_ID (#873).
// id (Stalwart 0.16 accepts any client id unless `require_client_registration`
// is enabled; 1.0 gets a client of its own, see defaultClientOptions), so
// refreshing/revoking those tokens must fall back to the same id instead of
// failing on the missing OAUTH_CLIENT_ID (#873).
export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
export interface ClientConfigOptions {
@@ -126,6 +128,18 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id, endSessionUrl };
}
/**
* Options for refreshing or revoking a token the password+TOTP login may have
* minted without a configured client. On Stalwart 1.0 that login signs in
* with a client it registers itself (lib/auth/stalwart-password-login.ts),
* whose id the refresh must present instead of the default one.
*/
export async function defaultClientOptions(serverId?: string | null): Promise<ClientConfigOptions> {
const { clientId, serverUrl } = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID });
if (clientId !== DEFAULT_CLIENT_ID) return { fallbackClientId: DEFAULT_CLIENT_ID };
return { fallbackClientId: await stalwartDefaultClientId(serverUrl, DEFAULT_CLIENT_ID, true) };
}
export function getClientSecret(serverId?: string | null): string {
const entry = getServerEntry(serverId);
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;