From 4f7c9c332bb2ba86acef7098d70b747966d317ef Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:53:12 +0200 Subject: [PATCH] feat: enhance OAuth auto-setup with dialog and validation for origin and issuer URLs --- app/admin/auth/page.tsx | 128 ++++++++++++++++++++++++----- app/api/admin/oauth/setup/route.ts | 27 ++++-- 2 files changed, 130 insertions(+), 25 deletions(-) diff --git a/app/admin/auth/page.tsx b/app/admin/auth/page.tsx index 1786aba4..426f9bab 100644 --- a/app/admin/auth/page.tsx +++ b/app/admin/auth/page.tsx @@ -70,16 +70,22 @@ export default function AdminAuthPage() { } const [setupRunning, setSetupRunning] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + const [setupOrigin, setSetupOrigin] = useState(''); + const [setupIssuer, setSetupIssuer] = useState(''); const [setupOauthOnly, setSetupOauthOnly] = useState(false); - async function handleAutoSetup() { + function openSetupDialog() { 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; + const origin = window.location.origin; + const jmapUrl = (currentValue('jmapServerUrl') as string | undefined)?.replace(/\/+$/, '') || ''; + setSetupOrigin(origin); + setSetupIssuer(jmapUrl || origin); + setSetupOauthOnly(currentValue('oauthOnly') === true); + setSetupOpen(true); + } + async function handleAutoSetup() { setSetupRunning(true); setMessage(null); try { @@ -87,7 +93,8 @@ export default function AdminAuthPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - origin: window.location.origin, + origin: setupOrigin.trim().replace(/\/+$/, ''), + issuerUrl: setupIssuer.trim().replace(/\/+$/, ''), oauthOnly: setupOauthOnly, }), }); @@ -95,9 +102,10 @@ export default function AdminAuthPage() { if (res.ok) { setMessage({ type: 'success', - text: `OAuth client ${data.action} on Stalwart. ${data.redirectUriCount} redirect URI(s) registered. Webmail config updated.`, + text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`, }); setEdits({}); + setSetupOpen(false); await fetchConfig(); } else { const detail = data.detail ? ` (${typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail).slice(0, 200)})` : ''; @@ -110,6 +118,9 @@ export default function AdminAuthPage() { } } + const setupOriginValid = /^https?:\/\/[^/]+$/.test(setupOrigin.trim().replace(/\/+$/, '')); + const setupIssuerValid = /^https?:\/\/[^/]+$/.test(setupIssuer.trim().replace(/\/+$/, '')); + const hasEdits = Object.keys(edits).length > 0; if (loading) { @@ -153,19 +164,9 @@ export default function AdminAuthPage() { 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 index b4cde06a..b1c63851 100644 --- a/app/api/admin/oauth/setup/route.ts +++ b/app/api/admin/oauth/setup/route.ts @@ -73,10 +73,15 @@ function buildRedirectUris(origin: string, localeList: readonly string[]): Recor interface SetupRequestBody { origin?: string; + issuerUrl?: string; locales?: string[]; oauthOnly?: boolean; } +function isValidOriginUrl(value: string): boolean { + return /^https?:\/\/[^/]+$/.test(value); +} + export async function POST(request: NextRequest) { try { const auth = await requireAdminAuth(); @@ -93,9 +98,16 @@ export async function POST(request: NextRequest) { const body = await request.json() as SetupRequestBody; const origin = (body.origin ?? '').trim().replace(/\/+$/, ''); - if (!/^https?:\/\/[^/]+$/.test(origin)) { + if (!isValidOriginUrl(origin)) { return NextResponse.json( - { error: 'Origin must be a URL like "https://mail.example.com" with no path.' }, + { error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' }, + { status: 400 }, + ); + } + const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, ''); + if (!isValidOriginUrl(issuerUrl)) { + return NextResponse.json( + { error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' }, { status: 400 }, ); } @@ -201,7 +213,7 @@ export async function POST(request: NextRequest) { oauthEnabled: true, oauthClientId: CLIENT_ID, oauthClientSecret: secret, - oauthIssuerUrl: origin, + oauthIssuerUrl: issuerUrl, }; if (oauthOnly) updates.oauthOnly = true; await configManager.setAdminConfig(updates); @@ -209,7 +221,8 @@ export async function POST(request: NextRequest) { await auditLog('admin.oauth_setup', { action, clientId: CLIENT_ID, - issuer: origin, + origin, + issuer: issuerUrl, redirectUriCount: localeList.length, oauthOnly, }, ip); @@ -217,7 +230,8 @@ export async function POST(request: NextRequest) { logger.info('Admin OAuth setup', { action, clientId: CLIENT_ID, - issuer: origin, + origin, + issuer: issuerUrl, locales: localeList.length, }); @@ -225,7 +239,8 @@ export async function POST(request: NextRequest) { ok: true, action, clientId: CLIENT_ID, - issuerUrl: origin, + origin, + issuerUrl, redirectUriCount: localeList.length, }); } catch (error) {