From da103ff06fa1336b545c97a9fa465a5740f1e831 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Apr 2026 01:37:18 +0200 Subject: [PATCH] feat: implement OAuth auto-setup functionality for Stalwart integration --- app/admin/auth/page.tsx | 77 ++++++- app/api/admin/oauth/setup/route.ts | 238 ++++++++++++++++++++++ app/api/auth/session/route.ts | 5 +- app/api/auth/totp-token-exchange/route.ts | 5 +- lib/oauth/token-exchange.ts | 20 +- 5 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 app/api/admin/oauth/setup/route.ts diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 54006784..1786aba4 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Save, Loader2, RotateCcw } from 'lucide-react'; +import { Save, Loader2, RotateCcw, Sparkles } from 'lucide-react'; import { apiFetch } from '@/lib/browser-navigation'; interface ConfigEntry { @@ -69,6 +69,47 @@ export default function AdminAuthPage() { } } + const [setupRunning, setSetupRunning] = useState(false); + const [setupOauthOnly, setSetupOauthOnly] = useState(false); + + async function handleAutoSetup() { + if (typeof window === 'undefined') return; + const oauthOnlyText = setupOauthOnly ? '\n\n • Disable password login (OAuth only)' : ''; + const ok = window.confirm( + `Auto-configure OAuth between this webmail and the connected Stalwart server?\n\nThis will:\n • Create or update an OAuth client called "bulwark-webmail" on the Stalwart server\n • Generate a new client secret\n • Register redirect URIs for ${window.location.origin}\n • Save OAuth settings to admin config (survives env changes)${oauthOnlyText}\n\nYour Stalwart user must have admin permissions.` + ); + if (!ok) return; + + setSetupRunning(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/oauth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + origin: window.location.origin, + oauthOnly: setupOauthOnly, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ + type: 'success', + text: `OAuth client ${data.action} on Stalwart. ${data.redirectUriCount} redirect URI(s) registered. Webmail config updated.`, + }); + setEdits({}); + await fetchConfig(); + } else { + const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; + setMessage({ type: 'error', text: (data.error || 'Setup failed') + detail }); + } + } catch (err) { + setMessage({ type: 'error', text: err instanceof Error ? err.message : 'Setup failed' }); + } finally { + setSetupRunning(false); + } + } + const hasEdits = Object.keys(edits).length > 0; if (loading) { @@ -100,6 +141,40 @@ export default function AdminAuthPage() { )} + {/* Auto-setup */} +
+
+
+
+ +

Auto-configure OAuth (Stalwart)

+
+

+ Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. + Requires your Stalwart account to have admin permissions. +

+ +
+ +
+
+ {/* OAuth */}
diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts new file mode 100644 index 00000000..b4cde06a --- /dev/null +++ b/app/api/admin/oauth/setup/route.ts @@ -0,0 +1,238 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { randomBytes } from 'node:crypto'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { configManager } from '@/lib/admin/config-manager'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { locales as ALL_LOCALES } from '@/i18n/routing'; + +const CLIENT_ID = 'bulwark-webmail'; +const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)'; +const JMAP_TIMEOUT_MS = 10_000; + +interface JmapMethodCall { + using: string[]; + methodCalls: Array<[string, Record, string]>; +} + +interface JmapMethodResponse { + methodResponses?: Array<[string, Record, string]>; +} + +async function fetchWithTimeout(url: string, init: Parameters[1]): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function jmapCall( + serverUrl: string, + authHeader: string, + body: JmapMethodCall, +): Promise { + const res = await fetchWithTimeout(`${serverUrl}/jmap/`, { + method: 'POST', + headers: { 'Authorization': authHeader, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`); + } + return res.json() as Promise; +} + +async function getStalwartAccountId( + serverUrl: string, + authHeader: string, +): Promise { + const res = await fetchWithTimeout(`${serverUrl}/.well-known/jmap`, { + method: 'GET', + headers: { 'Authorization': authHeader }, + }); + if (!res.ok) return null; + const session = await res.json() as { primaryAccounts?: Record }; + return session.primaryAccounts?.['urn:stalwart:jmap'] + ?? session.primaryAccounts?.['urn:ietf:params:jmap:mail'] + ?? Object.values(session.primaryAccounts ?? {})[0] + ?? null; +} + +function buildRedirectUris(origin: string, localeList: readonly string[]): Record { + const out: Record = {}; + for (const loc of localeList) { + out[`${origin}/${loc}/auth/callback`] = true; + } + return out; +} + +interface SetupRequestBody { + origin?: string; + locales?: string[]; + oauthOnly?: boolean; +} + +export async function POST(request: NextRequest) { + try { + const auth = await requireAdminAuth(); + if ('error' in auth) return auth.error; + + const ip = getClientIP(request); + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json( + { error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' }, + { status: 400 }, + ); + } + + const body = await request.json() as SetupRequestBody; + const origin = (body.origin ?? '').trim().replace(/\/+$/, ''); + if (!/^https?:\/\/[^/]+$/.test(origin)) { + return NextResponse.json( + { error: 'Origin must be a URL like "https://mail.example.com" with no path.' }, + { status: 400 }, + ); + } + const localeList = Array.isArray(body.locales) && body.locales.length > 0 + ? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l)) + : Array.from(ALL_LOCALES); + if (localeList.length === 0) { + return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 }); + } + const oauthOnly = body.oauthOnly === true; + + const accountId = await getStalwartAccountId(creds.serverUrl, creds.authHeader); + if (!accountId) { + return NextResponse.json( + { error: 'Could not resolve Stalwart account from JMAP session.' }, + { status: 502 }, + ); + } + + const queryRes = await jmapCall(creds.serverUrl, creds.authHeader, { + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [[ + 'x:OAuthClient/query', + { accountId, filter: { clientId: CLIENT_ID } }, + '0', + ]], + }); + + const queryEntry = queryRes.methodResponses?.[0]; + if (!queryEntry || queryEntry[0] === 'error') { + return NextResponse.json({ + error: 'Stalwart denied OAuthClient/query — your Stalwart account likely lacks admin permissions.', + detail: queryEntry?.[1], + }, { status: 403 }); + } + const existingIds = (queryEntry[1].ids as string[] | undefined) ?? []; + + const secret = randomBytes(32).toString('base64url'); + const redirectUris = buildRedirectUris(origin, localeList); + + let setArgs: Record; + let action: 'created' | 'updated'; + if (existingIds.length > 0) { + const targetId = existingIds[0]; + action = 'updated'; + setArgs = { + accountId, + update: { + [targetId]: { + secret, + redirectUris, + description: CLIENT_DESCRIPTION, + }, + }, + }; + } else { + action = 'created'; + setArgs = { + accountId, + create: { + new: { + clientId: CLIENT_ID, + description: CLIENT_DESCRIPTION, + secret, + redirectUris, + contacts: { [creds.username]: true }, + }, + }, + }; + } + + const setRes = await jmapCall(creds.serverUrl, creds.authHeader, { + using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'], + methodCalls: [['x:OAuthClient/set', setArgs, '0']], + }); + + const setEntry = setRes.methodResponses?.[0]; + if (!setEntry || setEntry[0] === 'error') { + return NextResponse.json({ + error: 'Stalwart denied OAuthClient/set — admin permissions required.', + detail: setEntry?.[1], + }, { status: 403 }); + } + const setBody = setEntry[1] as { + notCreated?: Record; + notUpdated?: Record; + }; + if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) { + return NextResponse.json( + { error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated }, + { status: 502 }, + ); + } + if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) { + return NextResponse.json( + { error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated }, + { status: 502 }, + ); + } + + await configManager.ensureLoaded(); + const updates: Record = { + oauthEnabled: true, + oauthClientId: CLIENT_ID, + oauthClientSecret: secret, + oauthIssuerUrl: origin, + }; + if (oauthOnly) updates.oauthOnly = true; + await configManager.setAdminConfig(updates); + + await auditLog('admin.oauth_setup', { + action, + clientId: CLIENT_ID, + issuer: origin, + redirectUriCount: localeList.length, + oauthOnly, + }, ip); + + logger.info('Admin OAuth setup', { + action, + clientId: CLIENT_ID, + issuer: origin, + locales: localeList.length, + }); + + return NextResponse.json({ + ok: true, + action, + clientId: CLIENT_ID, + issuerUrl: origin, + redirectUriCount: localeList.length, + }); + } catch (error) { + logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 06d11230..e9f9a7cf 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -9,6 +9,7 @@ import { clearStalwartAuthContextInStore, setStalwartAuthContextInStore, } from '@/lib/stalwart/auth-context'; +import { configManager } from '@/lib/admin/config-manager'; const COOKIE_OPTIONS = { ...getCookieOptions(), @@ -25,7 +26,9 @@ function getSlot(request: NextRequest): number { export async function POST(request: NextRequest) { try { - if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') { + const oauthEnabled = configManager.get('oauthEnabled', false); + const oauthOnly = configManager.get('oauthOnly', false); + if (oauthEnabled && oauthOnly) { return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 }); } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 39ef5bac..2ee7226b 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -5,6 +5,7 @@ import { discoverOAuth } from '@/lib/oauth/discovery'; import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { readFileEnv } from '@/lib/read-file-env'; +import { configManager } from '@/lib/admin/config-manager'; /** * Exchange basic auth credentials (with TOTP appended) for OAuth tokens. @@ -113,8 +114,8 @@ async function attemptAllStrategies( ): Promise { logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint }); - const clientId = process.env.OAUTH_CLIENT_ID; - const clientSecret = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); + const clientId = configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; + const clientSecret = configManager.get('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE); const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; const attempts: Array<{ strategy: string; error: string }> = []; diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index abeb2348..c54a92ed 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -2,18 +2,23 @@ import { logger } from '@/lib/logger'; 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'; -const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; +function getClientSecret(): string { + const adminSecret = configManager.get('oauthClientSecret', ''); + if (adminSecret) return adminSecret; + return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || ''; +} export function getRequiredConfig() { - const clientId = process.env.OAUTH_CLIENT_ID; - const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; - const issuerUrl = process.env.OAUTH_ISSUER_URL; + const clientId = configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; + const serverUrl = configManager.get('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL; + const issuerUrl = configManager.get('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL; if (!clientId || !serverUrl) { throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`); } const discoveryUrl = issuerUrl?.trim() || serverUrl; - if (issuerUrl !== undefined && !issuerUrl.trim()) { + 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 }; @@ -36,8 +41,9 @@ export async function getMetadata(): Promise { export function buildOAuthParams(base: Record): URLSearchParams { const { clientId } = getRequiredConfig(); const params = new URLSearchParams({ ...base, client_id: clientId }); - if (CLIENT_SECRET) { - params.set('client_secret', CLIENT_SECRET); + const secret = getClientSecret(); + if (secret) { + params.set('client_secret', secret); } return params; }