feat: multi-server JMAP support

This commit is contained in:
Linus Rath
2026-05-06 17:33:55 +02:00
parent 43475945bf
commit d3d79be64c
20 changed files with 818 additions and 108 deletions
+6
View File
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
switch (type) {
case 'boolean':
return value === 'true';
case 'json':
try {
return JSON.parse(value);
} catch {
return null;
}
case 'string':
case 'url':
case 'enum':
+168
View File
@@ -0,0 +1,168 @@
/**
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
*/
export interface JmapServerOAuthConfig {
clientId?: string;
issuerUrl?: string;
clientSecret?: string;
}
export interface JmapServerEntry {
id: string;
label: string;
url: string;
domains?: string[];
oauth?: JmapServerOAuthConfig;
}
export interface PublicJmapServerEntry {
id: string;
label: string;
url: string;
domains: string[];
oauth?: {
clientId?: string;
issuerUrl?: string;
};
}
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
function trimUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
}
function isHttpUrl(url: string): boolean {
try {
const u = new URL(url);
return u.protocol === 'https:' || u.protocol === 'http:';
} catch {
return false;
}
}
/** Parse the raw config value (may be array, string JSON, or null). */
export function parseJmapServers(raw: unknown): JmapServerEntry[] {
if (!raw) return [];
let value = raw;
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: JmapServerEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const e = item as Record<string, unknown>;
const id = typeof e.id === 'string' ? e.id.trim() : '';
const label = typeof e.label === 'string' ? e.label.trim() : '';
const url = typeof e.url === 'string' ? trimUrl(e.url) : '';
if (!id || !ID_RE.test(id) || seen.has(id)) continue;
if (!url || !isHttpUrl(url)) continue;
seen.add(id);
const domains = Array.isArray(e.domains)
? e.domains
.filter((d): d is string => typeof d === 'string')
.map((d) => d.trim().toLowerCase())
.filter(Boolean)
: [];
let oauth: JmapServerOAuthConfig | undefined;
if (e.oauth && typeof e.oauth === 'object') {
const o = e.oauth as Record<string, unknown>;
const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : '';
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
if (clientId || issuerUrl || clientSecret) {
oauth = {};
if (clientId) oauth.clientId = clientId;
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
if (clientSecret) oauth.clientSecret = clientSecret;
}
}
out.push({
id,
label: label || id,
url,
...(domains.length > 0 ? { domains } : {}),
...(oauth ? { oauth } : {}),
});
}
return out;
}
/** Strip secrets for client-side exposure. */
export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] {
return servers.map((s) => ({
id: s.id,
label: s.label,
url: s.url,
domains: s.domains ?? [],
...(s.oauth && (s.oauth.clientId || s.oauth.issuerUrl)
? {
oauth: {
...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}),
...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}),
},
}
: {}),
}));
}
export function findServerById(servers: JmapServerEntry[], id: string | null | undefined): JmapServerEntry | undefined {
if (!id) return undefined;
return servers.find((s) => s.id === id);
}
function normalizeUrl(url: string): string {
try {
const u = new URL(trimUrl(url));
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
} catch {
return trimUrl(url).toLowerCase();
}
}
export function findServerByUrl(servers: JmapServerEntry[], url: string | null | undefined): JmapServerEntry | undefined {
if (!url) return undefined;
const target = normalizeUrl(url);
return servers.find((s) => normalizeUrl(s.url) === target);
}
/** Find the server whose `domains` array matches the given email's domain (case-insensitive). */
export function findServerByEmailDomain(servers: JmapServerEntry[], email: string | null | undefined): JmapServerEntry | undefined {
if (!email || !email.includes('@')) return undefined;
const domain = email.split('@')[1]?.trim().toLowerCase();
if (!domain) return undefined;
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
}
/**
* Resolve a client-supplied JMAP URL to a trusted upstream URL by checking it
* against the configured server list and the global `jmapServerUrl`. Returns
* null when no match is found. Used by API routes that need to forward auth
* requests upstream without being tricked into hitting internal hosts.
*/
export function resolveTrustedJmapUrl(
requestedUrl: string | null | undefined,
globalServerUrl: string | null | undefined,
servers: JmapServerEntry[],
): string | null {
if (!requestedUrl) {
return globalServerUrl ? trimUrl(globalServerUrl) : null;
}
const target = normalizeUrl(requestedUrl);
if (globalServerUrl && normalizeUrl(globalServerUrl) === target) {
return trimUrl(globalServerUrl);
}
const matched = servers.find((s) => normalizeUrl(s.url) === target);
if (matched) return matched.url;
// No match — caller decides whether to honor the request anyway (e.g. when
// allowCustomJmapEndpoint is enabled).
return null;
}
+3 -1
View File
@@ -108,7 +108,7 @@ export interface AuditEntry {
}
/** Config keys that map to environment variables */
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
@@ -129,6 +129,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
+36 -15
View File
@@ -3,17 +3,31 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
function getClientSecret(): string {
function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() {
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
function getServerEntry(serverId?: string | null) {
if (!serverId) return undefined;
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
return findServerById(servers, serverId);
}
export function getRequiredConfig(serverId?: string | null) {
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;
const serverUrl = entry?.url || globalServerUrl;
const issuerUrl = entry?.oauth?.issuerUrl || globalIssuerUrl;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
@@ -21,11 +35,17 @@ export function getRequiredConfig() {
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
}
export async function getTokenEndpoint(): Promise<string> {
const { discoveryUrl } = getRequiredConfig();
function getClientSecret(serverId?: string | null): string {
const entry = getServerEntry(serverId);
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;
return getGlobalClientSecret();
}
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
@@ -33,15 +53,15 @@ export async function getTokenEndpoint(): Promise<string> {
return metadata.token_endpoint;
}
export async function getMetadata(): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig();
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl);
}
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
const { clientId } = getRequiredConfig(serverId);
const params = new URLSearchParams({ ...base, client_id: clientId });
const secret = getClientSecret();
const secret = getClientSecret(serverId);
if (secret) {
params.set('client_secret', secret);
}
@@ -58,15 +78,16 @@ export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
serverId?: string | null,
): Promise<TokenResult> {
const tokenEndpoint = await getTokenEndpoint();
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
}, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
+6
View File
@@ -2,8 +2,14 @@ const DEFAULT_SCOPES = 'openid email profile';
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
export function refreshTokenCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
}
/** Companion cookie storing which server entry id minted the refresh token at this slot. */
export function refreshTokenServerCookieName(slot: number): string {
return slot === 0 ? REFRESH_TOKEN_SERVER_COOKIE : `${REFRESH_TOKEN_SERVER_COOKIE}_${slot}`;
}