feat(auth): OIDC SSO for admin users — phase 1 (#798)

Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
Paul Nothaft
2026-07-16 08:54:26 +02:00
parent f0cdcddb92
commit ed5fc5ad5c
17 changed files with 1507 additions and 18 deletions
+1
View File
@@ -20,3 +20,4 @@ export { ApiTokensTab } from './tabs/ApiTokensTab';
export { WebhooksTab } from './tabs/WebhooksTab';
export { AccountingTab } from './tabs/AccountingTab';
export { WhatsAppTab } from './tabs/WhatsAppTab';
export { SsoTab } from './tabs/SsoTab';
@@ -0,0 +1,251 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, KeyRound, PlugZap, Copy, Check } from 'lucide-react';
import type { AxiosError } from 'axios';
import { Button, Card, Input, Loading } from '../../../components/common';
import { ssoService, SsoSettings, UpdateSsoSettings } from '../../../services/sso.service';
// SSO (OIDC) settings (#798, phase 1). Deliberately lean: issuer + client
// credentials, JIT toggle with default role, button label. Role-claim
// mapping is a follow-up. The client secret is write-only — the field stays
// blank and only overwrites when the admin types a new value.
export const SsoTab: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [form, setForm] = useState<SsoSettings | null>(null);
const [newSecret, setNewSecret] = useState('');
const [copied, setCopied] = useState(false);
const { isLoading } = useQuery({
queryKey: ['admin-sso-settings'],
queryFn: async () => {
const settings = await ssoService.getSettings();
setForm((prev) => prev ?? settings);
return settings;
},
});
const saveMutation = useMutation({
mutationFn: (data: UpdateSsoSettings) => ssoService.updateSettings(data),
onSuccess: () => {
toast.success(t('settings.sso.saved', 'SSO settings saved'));
setNewSecret('');
setForm(null); // re-init from the fresh GET (secret_set flag updates)
queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(error.response?.data?.error || t('settings.sso.saveError', 'Failed to save SSO settings'));
},
});
const testMutation = useMutation({
mutationFn: () => ssoService.testConnection(),
onSuccess: (result) => {
if (result.ok) {
toast.success(t('settings.sso.testOk', 'Discovery succeeded — issuer is reachable: {{issuer}}', { issuer: result.issuer }));
} else {
toast.error(result.error || t('settings.sso.testFailed', 'Discovery failed'));
}
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(error.response?.data?.error || t('settings.sso.testFailed', 'Discovery failed'));
},
});
if (isLoading || !form) {
return <Loading />;
}
const set = <K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) =>
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
const handleSave = () => {
const payload: UpdateSsoSettings = {
oidc_enabled: form.oidc_enabled,
oidc_issuer_url: form.oidc_issuer_url.trim(),
oidc_client_id: form.oidc_client_id.trim(),
oidc_autoprovision: form.oidc_autoprovision,
oidc_default_role: form.oidc_default_role,
oidc_button_label: form.oidc_button_label.trim(),
oidc_scopes: form.oidc_scopes.trim(),
};
if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim();
saveMutation.mutate(payload);
};
const copyRedirectUri = async () => {
try {
await navigator.clipboard.writeText(form.redirect_uri);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard unavailable — the URI is visible to copy by hand.
}
};
return (
<div className="space-y-6">
<Card>
<div className="p-6 space-y-4">
<div className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.sso.title', 'Single Sign-On (OIDC)')}
</h2>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.sso.intro', 'Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.')}
</p>
{/* Redirect URI for the IdP client registration */}
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 p-3">
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
{t('settings.sso.redirectUri', 'Redirect URI (register this on your IdP client)')}
</p>
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded bg-neutral-900 px-3 py-2 font-mono text-xs text-neutral-100">
{form.redirect_uri}
</code>
<button
type="button"
onClick={copyRedirectUri}
className="flex-shrink-0 rounded-md border border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-700 p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 transition-colors"
aria-label={t('common.copy', 'Copy')}
title={t('common.copy', 'Copy')}
>
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
<Input
label={t('settings.sso.issuerUrl', 'Issuer URL')}
placeholder="https://id.example.com/realms/main"
value={form.oidc_issuer_url}
onChange={(e) => set('oidc_issuer_url', e.target.value)}
/>
<p className="-mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.issuerHint', 'The base URL that serves /.well-known/openid-configuration.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('settings.sso.clientId', 'Client ID')}
value={form.oidc_client_id}
onChange={(e) => set('oidc_client_id', e.target.value)}
/>
<div>
<Input
label={t('settings.sso.clientSecret', 'Client Secret')}
type="password"
autoComplete="new-password"
placeholder={form.oidc_client_secret_set
? t('settings.sso.secretSetPlaceholder', '•••••• (saved — type to replace)')
: t('settings.sso.secretUnsetPlaceholder', 'Paste the client secret')}
value={newSecret}
onChange={(e) => setNewSecret(e.target.value)}
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.secretHint', 'Stored encrypted; never shown again. Leave blank to keep the current one.')}
</p>
</div>
</div>
<Input
label={t('settings.sso.scopes', 'Scopes')}
value={form.oidc_scopes}
onChange={(e) => set('oidc_scopes', e.target.value)}
/>
<label className="flex items-start gap-3 pt-1 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={form.oidc_autoprovision}
onChange={(e) => set('oidc_autoprovision', e.target.checked)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
{t('settings.sso.autoprovision', 'Auto-provision unknown users')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.autoprovisionHint', 'Create an admin account on first SSO login. Off: only existing/linked admins can sign in.')}
</span>
</span>
</label>
{form.oidc_autoprovision && (
<div className="max-w-xs">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.sso.defaultRole', 'Role for new users')}
</label>
<select
value={form.oidc_default_role}
onChange={(e) => set('oidc_default_role', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="viewer">{t('users.roles.viewer', 'Viewer')}</option>
<option value="editor">{t('users.roles.editor', 'Editor')}</option>
<option value="admin">{t('users.roles.admin', 'Admin')}</option>
<option value="super_admin">{t('users.roles.super_admin', 'Super Admin')}</option>
</select>
</div>
)}
<Input
label={t('settings.sso.buttonLabel', 'Login button label (optional)')}
placeholder={t('settings.sso.buttonLabelPlaceholder', 'Sign in with SSO')}
value={form.oidc_button_label}
onChange={(e) => set('oidc_button_label', e.target.value)}
/>
<label className="flex items-start gap-3 pt-1 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={form.oidc_enabled}
onChange={(e) => set('oidc_enabled', e.target.checked)}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
{t('settings.sso.enabled', 'Enable SSO login')}
</span>
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.enabledHint', 'Shows the SSO button on the admin login page. Requires issuer, client ID and secret.')}
</span>
</span>
</label>
<div className="flex items-center gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSave}
isLoading={saveMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
<Button
variant="outline"
leftIcon={<PlugZap className="w-4 h-4" />}
onClick={() => testMutation.mutate()}
isLoading={testMutation.isPending}
>
{t('settings.sso.test', 'Test connection')}
</Button>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.sso.testHint', 'Test runs OIDC discovery against the saved configuration — save first.')}
</p>
</div>
</Card>
</div>
);
};
SsoTab.displayName = 'SsoTab';
+36
View File
@@ -2090,6 +2090,32 @@
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
},
"sso": {
"title": "Single Sign-On (OIDC)",
"intro": "Admins melden sich über Ihren Identity Provider an (Keycloak, Authentik, Pocket ID oder jeder OIDC-konforme IdP). Die lokale Anmeldung mit E-Mail/Passwort bleibt als Fallback verfügbar.",
"redirectUri": "Redirect-URI (beim IdP-Client registrieren)",
"issuerUrl": "Issuer-URL",
"issuerHint": "Die Basis-URL, die /.well-known/openid-configuration ausliefert.",
"clientId": "Client-ID",
"clientSecret": "Client-Secret",
"secretSetPlaceholder": "•••••• (gespeichert — zum Ersetzen tippen)",
"secretUnsetPlaceholder": "Client-Secret einfügen",
"secretHint": "Verschlüsselt gespeichert; wird nie wieder angezeigt. Leer lassen, um das aktuelle zu behalten.",
"scopes": "Scopes",
"autoprovision": "Unbekannte Benutzer automatisch anlegen",
"autoprovisionHint": "Erstellt beim ersten SSO-Login ein Admin-Konto. Aus: Nur bestehende/verknüpfte Admins können sich anmelden.",
"defaultRole": "Rolle für neue Benutzer",
"buttonLabel": "Beschriftung des Login-Buttons (optional)",
"buttonLabelPlaceholder": "Mit SSO anmelden",
"enabled": "SSO-Login aktivieren",
"enabledHint": "Zeigt den SSO-Button auf der Admin-Anmeldeseite. Erfordert Issuer, Client-ID und Secret.",
"test": "Verbindung testen",
"testHint": "Der Test führt OIDC-Discovery gegen die gespeicherte Konfiguration aus — zuerst speichern.",
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
"testFailed": "Discovery fehlgeschlagen",
"saved": "SSO-Einstellungen gespeichert",
"saveError": "SSO-Einstellungen konnten nicht gespeichert werden"
}
},
"branding": {
@@ -3706,6 +3732,16 @@
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
},
"ssoDivider": "oder",
"ssoSignIn": "Mit SSO anmelden",
"ssoErrors": {
"config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.",
"state": "Die SSO-Anmeldung ist abgelaufen oder wurde manipuliert. Bitte erneut versuchen.",
"idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.",
"inactive": "Ihr Admin-Konto ist deaktiviert.",
"not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.",
"no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden."
}
},
"cssTemplates": {
+36
View File
@@ -1637,6 +1637,32 @@
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
},
"sso": {
"title": "Single Sign-On (OIDC)",
"intro": "Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.",
"redirectUri": "Redirect URI (register this on your IdP client)",
"issuerUrl": "Issuer URL",
"issuerHint": "The base URL that serves /.well-known/openid-configuration.",
"clientId": "Client ID",
"clientSecret": "Client Secret",
"secretSetPlaceholder": "•••••• (saved — type to replace)",
"secretUnsetPlaceholder": "Paste the client secret",
"secretHint": "Stored encrypted; never shown again. Leave blank to keep the current one.",
"scopes": "Scopes",
"autoprovision": "Auto-provision unknown users",
"autoprovisionHint": "Create an admin account on first SSO login. Off: only existing/linked admins can sign in.",
"defaultRole": "Role for new users",
"buttonLabel": "Login button label (optional)",
"buttonLabelPlaceholder": "Sign in with SSO",
"enabled": "Enable SSO login",
"enabledHint": "Shows the SSO button on the admin login page. Requires issuer, client ID and secret.",
"test": "Test connection",
"testHint": "Test runs OIDC discovery against the saved configuration — save first.",
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
"testFailed": "Discovery failed",
"saved": "SSO settings saved",
"saveError": "Failed to save SSO settings"
}
},
"analytics": {
@@ -3595,6 +3621,16 @@
"sessionExpired": "Your verification session expired. Please sign in again.",
"locked": "Account temporarily locked due to too many attempts. Try again later.",
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
},
"ssoDivider": "or",
"ssoSignIn": "Sign in with SSO",
"ssoErrors": {
"config": "SSO is misconfigured — check the SSO settings or sign in with email and password.",
"state": "The SSO sign-in expired or was tampered with. Please try again.",
"idp": "The identity provider rejected the sign-in. Please try again or use email and password.",
"inactive": "Your admin account is deactivated.",
"not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.",
"no_email": "Your identity provider supplied no email address — an account cannot be created."
}
},
"slideshow": {
@@ -61,6 +61,16 @@ export const AdminLoginPage: React.FC = () => {
}
}, [searchParams, t]);
// SSO callback failures land here as ?sso_error=<key> (#798) — surface a
// translated message instead of a silent bounce back to the form.
useEffect(() => {
const ssoError = searchParams.get('sso_error');
if (!ssoError) return;
const known = ['config', 'state', 'idp', 'inactive', 'not_provisioned', 'no_email'];
const key = known.includes(ssoError) ? ssoError : 'idp';
toast.error(t(`adminLogin.ssoErrors.${key}`));
}, [searchParams, t]);
// Fresh instance with no admin yet → send to first-run setup.
const { data: setupStatus } = useQuery({
queryKey: ['setup-status'],
@@ -337,6 +347,31 @@ export const AdminLoginPage: React.FC = () => {
>
{t('adminLogin.signIn')}
</Button>
{/* SSO (#798): plain navigation — the backend route redirects to
the IdP; the callback sets the same admin cookie as the local
login and lands on the dashboard. */}
{settingsData?.oidc_enabled === true && (
<>
<div className="flex items-center gap-3">
<div className="flex-1 border-t border-neutral-200" />
<span className="text-xs uppercase tracking-wide text-neutral-400">
{t('adminLogin.ssoDivider', 'or')}
</span>
<div className="flex-1 border-t border-neutral-200" />
</div>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
leftIcon={<KeyRound className="w-4 h-4" />}
onClick={() => { window.location.href = '/api/auth/admin/sso/login'; }}
>
{settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')}
</Button>
</>
)}
</form>
) : (
<form onSubmit={handleMfaSubmit} className="space-y-6">
+6 -1
View File
@@ -42,6 +42,7 @@ import {
WebhooksTab,
AccountingTab,
WhatsAppTab,
SsoTab,
} from '../../features/settings';
import { EmailConfigPage } from './EmailConfigPage';
import { BrandingPage } from './BrandingPage';
@@ -72,6 +73,7 @@ type TabType =
| 'email'
| 'moderation'
| 'security'
| 'sso'
| 'imageSecurity'
| 'seo'
| 'apiTokens'
@@ -104,7 +106,7 @@ const ALL_TAB_KEYS: TabType[] = [
'features', 'general', 'events', 'eventTypes',
'branding', 'categories', 'thumbnails', 'styling', 'cms',
'email', 'moderation',
'security', 'imageSecurity', 'seo',
'security', 'sso', 'imageSecurity', 'seo',
'apiTokens', 'webhooks',
'status', 'analytics', 'backup',
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
@@ -260,6 +262,7 @@ export const SettingsPage: React.FC = () => {
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
items: [
{ key: 'security', label: t('settings.security.title'), icon: Lock },
{ key: 'sso', label: t('settings.sso.title', 'Single Sign-On'), icon: KeyRound },
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield },
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
],
@@ -472,6 +475,8 @@ export const SettingsPage: React.FC = () => {
/>
)}
{activeTab === 'sso' && <SsoTab />}
{activeTab === 'security' && (
<SecurityTab
securitySettings={securitySettings}
@@ -96,6 +96,9 @@ export interface PublicSettings {
seo_meta_noindex?: boolean;
seo_meta_nofollow?: boolean;
seo_meta_noai?: boolean;
// OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login.
oidc_enabled?: boolean;
oidc_button_label?: string;
}
export const publicSettingsService = {
+56
View File
@@ -0,0 +1,56 @@
/**
* SSO (OIDC) Settings Service (#798)
* API client for the dedicated /admin/settings/sso endpoints — the client
* secret is write-only (never returned; `oidc_client_secret_set` flags it).
*/
import { api } from '../config/api';
export interface SsoSettings {
oidc_enabled: boolean;
oidc_issuer_url: string;
oidc_client_id: string;
oidc_client_secret_set: boolean;
oidc_autoprovision: boolean;
oidc_default_role: string;
oidc_button_label: string;
oidc_scopes: string;
redirect_uri: string;
}
export interface UpdateSsoSettings {
oidc_enabled?: boolean;
oidc_issuer_url?: string;
oidc_client_id?: string;
/** Only sent when the admin typed a new one; empty keeps the stored secret. */
oidc_client_secret?: string;
oidc_autoprovision?: boolean;
oidc_default_role?: string;
oidc_button_label?: string;
oidc_scopes?: string;
}
export interface SsoTestResult {
ok: boolean;
issuer?: string;
authorization_endpoint?: string;
token_endpoint?: string;
error?: string;
}
export const ssoService = {
async getSettings(): Promise<SsoSettings> {
const response = await api.get<SsoSettings>('/admin/settings/sso');
return response.data;
},
async updateSettings(data: UpdateSsoSettings): Promise<void> {
await api.put('/admin/settings/sso', data);
},
// Server-side discovery probe against the SAVED config.
async testConnection(): Promise<SsoTestResult> {
const response = await api.post<SsoTestResult>('/admin/settings/sso/test');
return response.data;
},
};