mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import { logger } from '@/lib/logger';
|
|
import { discoverOAuth } from '@/lib/oauth/discovery';
|
|
import type { EndpointValidator, OAuthMetadata } from '@/lib/oauth/discovery';
|
|
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 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 {
|
|
/**
|
|
* Client id to use when none is configured. Only pass this for operations on
|
|
* tokens that may have been minted by the TOTP login fallback; flows that
|
|
* initiate OAuth (authorize URL, code exchange, SSO) must keep failing loudly
|
|
* so a misconfiguration surfaces at login rather than as a broken session.
|
|
*/
|
|
fallbackClientId?: string;
|
|
}
|
|
|
|
function httpOrigin(url: string): string | null {
|
|
try {
|
|
const u = new URL(url);
|
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
|
|
if (u.username || u.password) return null;
|
|
return u.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// SSRF guard for OAuth discovery. `discoveryUrl` comes from admin config (the
|
|
// issuer or the JMAP server, see getRequiredConfig), and discovery has already
|
|
// connected to it, so endpoints on that same origin are trusted the way the
|
|
// configured JMAP server is in stalwart-context. Rejecting them broke
|
|
// split-horizon DNS, where the issuer's public hostname resolves to an
|
|
// in-cluster address (#1028). Every other endpoint in the document must still
|
|
// resolve publicly, so a spoofed or compromised document cannot aim the token
|
|
// or revocation request at another internal host. When
|
|
// `oauthAllowPrivateEndpoints` is set, the admin opts out of the guard
|
|
// entirely, for IdPs that advertise endpoints on other internal hosts.
|
|
export function getDiscoveryValidator(discoveryUrl: string): EndpointValidator | undefined {
|
|
const allowPrivate = configManager.get<boolean>('oauthAllowPrivateEndpoints', false);
|
|
if (allowPrivate) return undefined;
|
|
const issuerOrigin = httpOrigin(discoveryUrl);
|
|
return async (endpoint: string) =>
|
|
(issuerOrigin !== null && httpOrigin(endpoint) === issuerOrigin) || isPublicHttpUrl(endpoint);
|
|
}
|
|
|
|
function getGlobalClientSecret(): string {
|
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
|
if (adminSecret) return adminSecret;
|
|
|
|
const adminFileSecret = readFileEnv(
|
|
configManager.get<string>('oauthClientSecretFile', ''),
|
|
);
|
|
if (adminFileSecret) return adminFileSecret;
|
|
|
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
|
}
|
|
|
|
function getServerEntry(serverId?: string | null) {
|
|
if (!serverId) return undefined;
|
|
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
|
return findServerById(servers, serverId);
|
|
}
|
|
|
|
/**
|
|
* Strip a trailing JMAP session path so OAuth discovery hits the right base.
|
|
*
|
|
* `discoverOAuth` appends `/.well-known/...` to this base, so JMAP_SERVER_URL set to
|
|
* the session URL (`.../jmap/session` or `.../.well-known/jmap`, common so the client
|
|
* can skip the 307 redirect a preflighted CORS request cannot follow) makes discovery
|
|
* fetch `<session-url>/.well-known/oauth-authorization-server`, get a 404, and refresh
|
|
* fail with "OAuth token endpoint not found".
|
|
*
|
|
* Only the session suffix (and any query or fragment) is removed, not the whole path:
|
|
* discovery also runs against OAUTH_ISSUER_URL, and a path-based issuer (for example a
|
|
* Keycloak realm at `/realms/<name>`) must keep its path. So this is deliberately not
|
|
* `new URL(url).origin`. (#971)
|
|
*/
|
|
function discoveryBase(url: string): string {
|
|
try {
|
|
const u = new URL(url);
|
|
u.pathname = u.pathname.replace(/\/(?:jmap\/session|\.well-known\/jmap)\/*$/, '');
|
|
u.search = '';
|
|
u.hash = '';
|
|
return u.toString().replace(/\/+$/, '');
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
export function getRequiredConfig(serverId?: string | null, options?: ClientConfigOptions) {
|
|
const entry = getServerEntry(serverId);
|
|
|
|
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
|
const globalServerUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
|
const globalIssuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
|
|
|
const clientId = entry?.oauth?.clientId || globalClientId || options?.fallbackClientId;
|
|
const serverUrl = entry?.url || globalServerUrl;
|
|
// When the user picked a server, discovery must stay on that server: its
|
|
// own issuer if configured, otherwise the server itself. The global
|
|
// OAUTH_ISSUER_URL only applies when no server entry was resolved, or it
|
|
// would silently send every server's SSO to server 1's IdP. (#952)
|
|
const issuerUrl = entry ? (entry.oauth?.issuerUrl || entry.url) : globalIssuerUrl;
|
|
|
|
if (!clientId || !serverUrl) {
|
|
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
|
}
|
|
const discoveryUrl = discoveryBase(issuerUrl?.trim() || serverUrl);
|
|
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
|
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
|
}
|
|
// Belongs to the same provider as the issuer, so the same rule applies:
|
|
// the global OAUTH_END_SESSION_URL only without a server entry (#1279).
|
|
const endSessionUrl = entry
|
|
? entry.oauth?.endSessionUrl
|
|
: (configManager.get<string>('oauthEndSessionUrl', '') || process.env.OAUTH_END_SESSION_URL)?.trim() || undefined;
|
|
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;
|
|
return getGlobalClientSecret();
|
|
}
|
|
|
|
/**
|
|
* Whether the server's client is confidential. Tokens minted for it can only
|
|
* be refreshed with the secret, which never leaves this server - so a paired
|
|
* phone renews them through the webmail (see lib/auth/pair-bundle.ts).
|
|
*/
|
|
export function hasClientSecret(serverId?: string | null): boolean {
|
|
return getClientSecret(serverId) !== '';
|
|
}
|
|
|
|
export async function getTokenEndpoint(serverId?: string | null, options?: ClientConfigOptions): Promise<string> {
|
|
const { discoveryUrl } = getRequiredConfig(serverId, options);
|
|
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
|
|
if (!metadata?.token_endpoint) {
|
|
throw new Error('OAuth token endpoint not found');
|
|
}
|
|
return metadata.token_endpoint;
|
|
}
|
|
|
|
export async function getMetadata(serverId?: string | null, options?: ClientConfigOptions): Promise<OAuthMetadata | null> {
|
|
const { discoveryUrl } = getRequiredConfig(serverId, options);
|
|
return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
|
|
}
|
|
|
|
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null, options?: ClientConfigOptions): URLSearchParams {
|
|
const { clientId } = getRequiredConfig(serverId, options);
|
|
const params = new URLSearchParams({ ...base, client_id: clientId });
|
|
const secret = getClientSecret(serverId);
|
|
if (secret) {
|
|
params.set('client_secret', secret);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
export interface TokenResult {
|
|
access_token: string;
|
|
expires_in: number;
|
|
refresh_token?: string;
|
|
/** OIDC id token, kept for RP-initiated logout (`id_token_hint`). */
|
|
id_token?: string;
|
|
}
|
|
|
|
export async function exchangeCodeForTokens(
|
|
code: string,
|
|
codeVerifier: string,
|
|
redirectUri: string,
|
|
serverId?: string | null,
|
|
): Promise<TokenResult> {
|
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
|
|
|
const params = buildOAuthParams({
|
|
grant_type: 'authorization_code',
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
code_verifier: codeVerifier,
|
|
}, serverId);
|
|
|
|
// The body carries the code, the PKCE verifier and the client secret. The
|
|
// endpoint was validated; a redirect would re-send all of it to a host
|
|
// that was not, so none is followed.
|
|
const tokenResponse = await fetch(tokenEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: params.toString(),
|
|
redirect: 'error',
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorText = await tokenResponse.text();
|
|
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
|
|
throw new Error('Token exchange failed');
|
|
}
|
|
|
|
const tokens = await tokenResponse.json();
|
|
|
|
if (!tokens.access_token) {
|
|
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
|
|
throw new Error('Invalid token response');
|
|
}
|
|
|
|
return {
|
|
access_token: tokens.access_token,
|
|
expires_in: tokens.expires_in || 3600,
|
|
refresh_token: tokens.refresh_token,
|
|
id_token: typeof tokens.id_token === 'string' ? tokens.id_token : undefined,
|
|
};
|
|
}
|