refactor: move Stalwart's password and TOTP login into a shared helper

The password+TOTP login drove Stalwart's structured login (`/api/auth`,
then `/auth/token` with PKCE) inline. Linking the mobile app needs the
same exchange to mint a phone's own grant, so it now lives in
`lib/auth/stalwart-password-login.ts` and the route only maps its
result to the responses it always gave.
This commit is contained in:
Linus Rath
2026-09-27 04:11:30 +02:00
parent 5ce8c89c51
commit ce5f177d53
2 changed files with 246 additions and 138 deletions
+42 -138
View File
@@ -5,45 +5,27 @@ import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oaut
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { DisallowedUrlError, fetchPublicUrl, isPublicHttpUrl } from '@/lib/security/url-guard';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
*
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
* splits the secret on `$`, so any TOTP appended to the password is verified
* verbatim against the password hash and fails. The MFA token must instead be
* supplied as a distinct field through the structured login endpoint.
*
* This route drives that flow server-side (avoiding browser CORS against the
* mail server, same as OAuth discovery):
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* receiving a short-lived authorization `clientCode`.
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
* the code (with PKCE) for access/refresh tokens.
* Stalwart 0.16+ rejects TOTP over HTTP Basic, so a TOTP login goes through
* its structured login endpoint instead (see stalwartPasswordLogin). This
* route drives that flow server-side (avoiding browser CORS against the mail
* server, same as OAuth discovery) and keeps the refresh token in the slot's
* httpOnly cookie.
*
* Token-based auth also survives TOTP rotation, unlike basic auth which embeds
* the (≈30s) code in every request.
*/
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;
}
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
async function attemptLogin(
upstreamUrl: string,
username: string,
@@ -54,13 +36,7 @@ async function attemptLogin(
serverId: string | null,
upstreamTrusted: boolean,
): Promise<NextResponse> {
const base = trimUrl(upstreamUrl);
// A user-supplied endpoint (allowCustomJmapEndpoint) must connect through
// the rebinding-safe fetch; admin-configured servers may live on private
// addresses and use the plain one.
const upstreamFetch: typeof fetch = upstreamTrusted
? fetch
: ((input, init) => fetchPublicUrl(String(input), init as Parameters<typeof fetchPublicUrl>[1]) as unknown as Promise<Response>);
const base = upstreamUrl.replace(/\/+$/, '');
// Per-server OAuth credentials override the global ones when the requested
// server entry has its own oauth block configured.
@@ -76,122 +52,50 @@ async function attemptLogin(
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|| '';
// 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);
const result = await stalwartPasswordLogin({
serverUrl: base,
username,
password,
totp,
clientId,
clientSecret,
redirectUri,
trusted: upstreamTrusted,
});
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
try {
const loginResponse = await upstreamFetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: username,
accountSecret: password,
...(totp ? { mfaToken: totp } : {}),
clientId,
redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
const detail = await upstreamDetail(loginResponse, upstreamTrusted);
logger.warn('TOTP login: /api/auth rejected request', { status: loginResponse.status });
if (!result.ok) {
switch (result.error) {
// A 404 means the server predates the structured login endpoint; let the
// caller fall back to the legacy basic-auth path.
return NextResponse.json(
{ error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_failed', detail },
{ status: loginResponse.status === 404 ? 404 : 502 },
);
case 'login_endpoint_missing':
return NextResponse.json({ error: 'login_endpoint_missing', detail: result.detail }, { status: 404 });
case 'login_rejected':
return NextResponse.json({ error: 'login_failed', detail: result.detail }, { status: 502 });
case 'invalid_server_url':
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
case 'login_unreachable':
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
case 'totp_required':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'invalid_credentials':
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
case 'login_failed':
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
case 'token_exchange_failed':
return NextResponse.json(
result.detail !== undefined ? { error: 'token_exchange_failed', detail: result.detail } : { error: 'token_exchange_failed' },
{ status: 502 },
);
}
login = await loginResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('TOTP login: rejected non-public server URL at connect time');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
logger.warn('TOTP login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'login_unreachable' }, { status: 502 });
}
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
return NextResponse.json({ error: 'totp_required' }, { status: 401 });
case 'failure':
default:
return NextResponse.json({ error: 'invalid_credentials' }, { status: 401 });
}
if (!login.client_code) {
logger.warn('TOTP login: authenticated response missing client_code');
return NextResponse.json({ error: 'login_failed' }, { status: 502 });
}
// Step 2: exchange the authorization code for tokens.
const tokenParams = new URLSearchParams({
grant_type: 'authorization_code',
code: login.client_code,
client_id: clientId,
redirect_uri: redirectUri,
code_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
if (clientSecret) tokenParams.set('client_secret', clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
const tokenResponse = await upstreamFetch(`${base}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const detail = await upstreamDetail(tokenResponse, upstreamTrusted);
logger.warn('TOTP login: token exchange failed', { status: tokenResponse.status, detail });
return NextResponse.json({ error: 'token_exchange_failed', detail }, { status: 502 });
}
tokens = await tokenResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('TOTP login: rejected non-public server URL at connect time');
return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 });
}
logger.warn('TOTP login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'token_exchange_failed' }, { status: 502 });
}
if (!tokens.access_token) {
return NextResponse.json({ error: 'token_exchange_failed', detail: 'Response missing access_token' }, { status: 502 });
}
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(
{ access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
slot,
serverId,
);
return await storeAndRespond(result.tokens, slot, serverId);
}
/**
* The start of an upstream error body, for the login screen. Only from a
* server the admin configured: a user-chosen server URL makes this route a
* way to read what any public address answers, and together with a gap in
* the address guard, internal ones too.
*/
async function upstreamDetail(response: Response, trusted: boolean): Promise<string | undefined> {
if (!trusted) return undefined;
return (await response.text()).substring(0, 500);
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
export async function POST(request: NextRequest) {
+204
View File
@@ -0,0 +1,204 @@
import { logger } from '@/lib/logger';
import { DisallowedUrlError, fetchPublicUrl } from '@/lib/security/url-guard';
import { generateCodeVerifier, generateCodeChallenge } from '@/lib/oauth/pkce';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens at Stalwart.
*
* Stalwart 0.16+ no longer accepts the legacy `password$totp` convention over
* HTTP Basic auth: its Basic decoder hardcodes `mfa_token: None` and never
* splits the secret on `$`, so any TOTP appended to the password is verified
* verbatim against the password hash and fails. The MFA token must instead be
* supplied as a distinct field through the structured login endpoint:
* 1. POST {serverUrl}/api/auth -> authenticate with a separate `mfaToken`,
* receiving a short-lived authorization `clientCode`.
* 2. POST {serverUrl}/auth/token (grant_type=authorization_code) -> exchange
* the code (with PKCE) for access/refresh tokens.
*
* 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).
*/
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;
password: string;
totp?: string;
clientId: string;
/** Sent at the token endpoint when set; harmless for public clients. */
clientSecret?: string;
redirectUri: string;
/**
* Whether `serverUrl` is admin-configured. A user-supplied endpoint
* (allowCustomJmapEndpoint) must connect through the rebinding-safe fetch;
* admin-configured servers may live on private addresses.
*/
trusted: boolean;
}
export interface StalwartTokens {
access_token: string;
expires_in?: number;
refresh_token?: string;
}
export type StalwartPasswordLoginResult =
| { ok: true; tokens: StalwartTokens; tokenEndpoint: 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.
* - `totp_required` / `invalid_credentials`: the credential verdict.
*/
error:
| 'login_endpoint_missing'
| 'login_rejected'
| 'login_unreachable'
| 'invalid_server_url'
| 'totp_required'
| 'invalid_credentials'
| 'login_failed'
| 'token_exchange_failed';
/** Upstream HTTP status of the failing step, when there was one. */
status?: number;
/** Start of the upstream error body - only from a trusted server. */
detail?: string;
};
function trimUrl(url: string): string {
return url.replace(/\/+$/, '');
}
/**
* The start of an upstream error body, for the login screen. Only from a
* server the admin configured: a user-chosen server URL makes this a way to
* read what any public address answers, and together with a gap in the
* address guard, internal ones too.
*/
async function upstreamDetail(response: Response, trusted: boolean): Promise<string | undefined> {
if (!trusted) return undefined;
return (await response.text()).substring(0, 500);
}
export async function stalwartPasswordLogin(
opts: StalwartPasswordLoginOptions,
): Promise<StalwartPasswordLoginResult> {
const base = trimUrl(opts.serverUrl);
const upstreamFetch: typeof fetch = opts.trusted
? fetch
: ((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);
// Step 1: structured login with a separate MFA token.
let login: LoginResult;
try {
const loginResponse = await upstreamFetch(`${base}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'authCode',
accountName: opts.username,
accountSecret: opts.password,
...(opts.totp ? { mfaToken: opts.totp } : {}),
clientId: opts.clientId,
redirectUri: opts.redirectUri,
codeChallenge: challenge,
codeChallengeMethod: 'S256',
}),
});
if (!loginResponse.ok) {
const detail = await upstreamDetail(loginResponse, opts.trusted);
logger.warn('Stalwart password login: /api/auth rejected request', { status: loginResponse.status });
return {
ok: false,
error: loginResponse.status === 404 ? 'login_endpoint_missing' : 'login_rejected',
status: loginResponse.status,
detail,
};
}
login = await loginResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('Stalwart password login: rejected non-public server URL at connect time');
return { ok: false, error: 'invalid_server_url' };
}
logger.warn('Stalwart password login: /api/auth request failed', { error: err instanceof Error ? err.message : String(err) });
return { ok: false, error: 'login_unreachable' };
}
switch (login.type) {
case 'authenticated':
break;
case 'mfaRequired':
return { ok: false, error: 'totp_required' };
case 'failure':
default:
return { ok: false, error: 'invalid_credentials' };
}
if (!login.client_code) {
logger.warn('Stalwart password login: authenticated response missing client_code');
return { ok: false, error: 'login_failed' };
}
// 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_verifier: verifier,
});
// Confidential clients still send their secret; harmless for public clients.
if (opts.clientSecret) tokenParams.set('client_secret', opts.clientSecret);
let tokens: { access_token?: string; expires_in?: number; refresh_token?: string };
try {
const tokenResponse = await upstreamFetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const detail = await upstreamDetail(tokenResponse, opts.trusted);
logger.warn('Stalwart password login: token exchange failed', { status: tokenResponse.status, detail });
return { ok: false, error: 'token_exchange_failed', status: tokenResponse.status, detail };
}
tokens = await tokenResponse.json();
} catch (err) {
if (err instanceof DisallowedUrlError) {
logger.warn('Stalwart password login: rejected non-public server URL at connect time');
return { ok: false, error: 'invalid_server_url' };
}
logger.warn('Stalwart password login: token endpoint failed', { error: err instanceof Error ? err.message : String(err) });
return { ok: false, error: 'token_exchange_failed' };
}
if (!tokens.access_token) {
return { ok: false, error: 'token_exchange_failed', detail: 'Response missing access_token' };
}
return {
ok: true,
tokens: { access_token: tokens.access_token, expires_in: tokens.expires_in, refresh_token: tokens.refresh_token },
tokenEndpoint,
};
}