mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: sign in with password and TOTP on Stalwart 1.0
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user