mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user