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.
+
+
+ setSetupOauthOnly(e.target.checked)}
+ className="h-3.5 w-3.5 rounded border-input"
+ disabled={setupRunning}
+ />
+ Also enable “OAuth only” (hide password login)
+
+
+
+ {setupRunning ? : }
+ {setupRunning ? 'Configuring…' : 'Set up automagically'}
+
+
+
+
{/* 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;
}