feat: implement OAuth auto-setup functionality for Stalwart integration
This commit is contained in:
+76
-1
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-setup */}
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<h3 className="text-sm font-medium text-foreground">Auto-configure OAuth (Stalwart)</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
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.
|
||||
</p>
|
||||
<label className="inline-flex items-center gap-2 mt-3 text-xs text-foreground select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupOauthOnly}
|
||||
onChange={(e) => setSetupOauthOnly(e.target.checked)}
|
||||
className="h-3.5 w-3.5 rounded border-input"
|
||||
disabled={setupRunning}
|
||||
/>
|
||||
Also enable “OAuth only” (hide password login)
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAutoSetup}
|
||||
disabled={setupRunning}
|
||||
className="shrink-0 inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
|
||||
>
|
||||
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
|
||||
{setupRunning ? 'Configuring…' : 'Set up automagically'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OAuth */}
|
||||
<Section title="OAuth / OpenID Connect">
|
||||
<Toggle label="OAuth Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
|
||||
@@ -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, unknown>, string]>;
|
||||
}
|
||||
|
||||
interface JmapMethodResponse {
|
||||
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, init: Parameters<typeof fetch>[1]): Promise<Response> {
|
||||
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<JmapMethodResponse> {
|
||||
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<JmapMethodResponse>;
|
||||
}
|
||||
|
||||
async function getStalwartAccountId(
|
||||
serverUrl: string,
|
||||
authHeader: string,
|
||||
): Promise<string | null> {
|
||||
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<string, string> };
|
||||
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<string, true> {
|
||||
const out: Record<string, true> = {};
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
notUpdated?: Record<string, unknown>;
|
||||
};
|
||||
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<string, unknown> = {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>('oauthEnabled', false);
|
||||
const oauthOnly = configManager.get<boolean>('oauthOnly', false);
|
||||
if (oauthEnabled && oauthOnly) {
|
||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NextResponse> {
|
||||
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<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = configManager.get<string>('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 }> = [];
|
||||
|
||||
|
||||
@@ -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<string>('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<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;
|
||||
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<OAuthMetadata | null> {
|
||||
export function buildOAuthParams(base: Record<string, string>): 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user