feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91

SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-06-05 16:04:13 +02:00
parent f85552a475
commit 3a707e2e3f
18 changed files with 1326 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
import { Suspense, lazy, useEffect, useState, type ReactNode } from 'react'
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary'
import { ConfigProvider, Spin } from 'antd'
@@ -79,11 +79,37 @@ const antdTheme = {
function RequireAuth({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.set)
const location = useLocation()
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />
// Wenn kein Store-User da ist (z.B. direkt nach SSO-Callback: Cookie
// gesetzt, sessionStorage leer — oder Hard-Refresh), einmal /auth/me
// probieren, bevor wir nach /login umleiten.
const [checking, setChecking] = useState(user === null)
useEffect(() => {
if (user !== null) {
setChecking(false)
return
}
let cancelled = false
apiClient.get('/auth/me')
.then((r) => {
if (!cancelled && isEnvelope(r.data)) setUser(r.data.data as SessionUser)
})
.catch(() => { /* 401 → Interceptor leitet auf /login */ })
.finally(() => { if (!cancelled) setChecking(false) })
return () => { cancelled = true }
}, [user, setUser])
if (user) return <>{children}</>
if (checking) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)
}
return <>{children}</>
return <Navigate to="/login" replace state={{ from: location }} />
}
function SetupGate({ children }: { children: ReactNode }) {

View File

@@ -289,6 +289,24 @@
"forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
"sso": {
"login": "Mit SSO anmelden",
"err": {
"no_account": "Kein EdgeGuard-Konto für diese E-Mail. Bitte wende dich an einen Administrator.",
"disabled": "Dieses Konto ist deaktiviert.",
"unverified": "Die E-Mail-Adresse ist beim Identity-Provider nicht verifiziert.",
"state": "Sicherheitsprüfung fehlgeschlagen (State). Bitte erneut versuchen.",
"nonce": "Sicherheitsprüfung fehlgeschlagen (Nonce). Bitte erneut versuchen.",
"token": "Das Token vom Identity-Provider konnte nicht verifiziert werden.",
"exchange": "Code-Austausch mit dem Identity-Provider fehlgeschlagen.",
"expired": "Die Anmeldesitzung ist abgelaufen. Bitte erneut versuchen.",
"denied": "Anmeldung beim Identity-Provider abgebrochen.",
"subject_mismatch": "Die Identität passt nicht zum hinterlegten Konto.",
"config": "SSO ist nicht korrekt konfiguriert.",
"server": "Interner Fehler bei der SSO-Anmeldung.",
"generic": "SSO-Anmeldung fehlgeschlagen."
}
},
"totp": {
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"verify": "Code bestätigen",
@@ -907,6 +925,24 @@
"configPreviewBtn": "Vorschau laden",
"configPreviewHint": "Rendert die gewählte Service-Config aus dem aktuellen DB-State. Nur Lesezugriff — es wird nichts auf Disk geschrieben oder neu geladen.",
"configCopied": "Config in Zwischenablage kopiert",
"oidc": {
"title": "Single Sign-On (OIDC)",
"intro": "SSO-Login per OpenID Connect (z. B. Keycloak), zusätzlich zum lokalen Login. Anmeldung gelingt nur für bereits angelegte Benutzer; die Rolle wird in EdgeGuard verwaltet.",
"enabled": "SSO aktiviert",
"issuerUrl": "Issuer-URL",
"issuerHint": "Basis-URL des Realms, z. B. https://keycloak.example.com/realms/edgeguard",
"clientId": "Client-ID",
"clientSecret": "Client-Secret",
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
"secretUnset": "Noch kein Secret gespeichert.",
"scopes": "Scopes",
"emailClaim": "E-Mail-Claim",
"buttonLabel": "Button-Beschriftung",
"redirectUri": "Redirect-URI",
"redirectHint": "Diese URL im Keycloak-Client als gültige Redirect-URI eintragen (pro Cluster-Node die jeweilige FQDN).",
"saved": "OIDC-Einstellungen gespeichert",
"saveFailed": "Speichern der OIDC-Einstellungen fehlgeschlagen"
},
"passwordCardTitle": "Admin-Passwort ändern",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
@@ -1287,6 +1323,7 @@
"common": {
"yes": "Ja",
"no": "Nein",
"or": "oder",
"save": "Speichern",
"cancel": "Abbrechen",
"loading": "Lädt …",

View File

@@ -289,6 +289,24 @@
"forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
"sso": {
"login": "Sign in with SSO",
"err": {
"no_account": "No EdgeGuard account for this email. Please contact an administrator.",
"disabled": "This account is disabled.",
"unverified": "The email address is not verified at the identity provider.",
"state": "Security check failed (state). Please try again.",
"nonce": "Security check failed (nonce). Please try again.",
"token": "Could not verify the token from the identity provider.",
"exchange": "Code exchange with the identity provider failed.",
"expired": "The sign-in session expired. Please try again.",
"denied": "Sign-in at the identity provider was cancelled.",
"subject_mismatch": "The identity does not match the linked account.",
"config": "SSO is not configured correctly.",
"server": "Internal error during SSO sign-in.",
"generic": "SSO sign-in failed."
}
},
"totp": {
"prompt": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify code",
@@ -907,6 +925,24 @@
"configPreviewBtn": "Load preview",
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.",
"configCopied": "Config copied to clipboard",
"oidc": {
"title": "Single Sign-On (OIDC)",
"intro": "SSO login via OpenID Connect (e.g. Keycloak), in addition to local login. Only existing users can sign in; the role is managed in EdgeGuard.",
"enabled": "SSO enabled",
"issuerUrl": "Issuer URL",
"issuerHint": "Realm base URL, e.g. https://keycloak.example.com/realms/edgeguard",
"clientId": "Client ID",
"clientSecret": "Client secret",
"secretSet": "Stored — leave empty to keep unchanged.",
"secretUnset": "No secret stored yet.",
"scopes": "Scopes",
"emailClaim": "Email claim",
"buttonLabel": "Button label",
"redirectUri": "Redirect URI",
"redirectHint": "Register this URL as a valid redirect URI in the Keycloak client (per cluster node its own FQDN).",
"saved": "OIDC settings saved",
"saveFailed": "Failed to save OIDC settings"
},
"passwordCardTitle": "Change admin password",
"currentPassword": "Current password",
"newPassword": "New password",
@@ -1287,6 +1323,7 @@
"common": {
"yes": "Yes",
"no": "No",
"or": "or",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading …",

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { KeyOutlined } from '@ant-design/icons'
import { useEffect, useState } from 'react'
import { Button, Card, Divider, Form, Input, message, Typography } from 'antd'
import { KeyOutlined, LoginOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -22,6 +22,28 @@ export default function LoginPage({ onLogin }: Props) {
const [totpRequired, setTotpRequired] = useState(false)
const [totpCode, setTotpCode] = useState('')
const [verifying, setVerifying] = useState(false)
const [ssoEnabled, setSsoEnabled] = useState(false)
const [ssoLabel, setSsoLabel] = useState('')
// SSO-Verfügbarkeit prüfen + evtl. ?sso_error vom Callback anzeigen.
useEffect(() => {
apiClient.get('/auth/oidc/settings')
.then((r) => {
if (!isEnvelope(r.data)) return
const d = r.data.data as { enabled?: boolean; button_label?: string }
setSsoEnabled(!!d.enabled)
setSsoLabel(d.button_label || '')
})
.catch(() => { /* SSO optional */ })
const reason = new URLSearchParams(window.location.search).get('sso_error')
if (reason) {
const key = `auth.sso.err.${reason}`
const txt = t(key)
message.error(txt === key ? t('auth.sso.err.generic') : txt)
window.history.replaceState({}, '', window.location.pathname)
}
}, [t])
const onFinish = async (vals: LoginValues) => {
try {
@@ -106,6 +128,20 @@ export default function LoginPage({ onLogin }: Props) {
</div>
)}
{!totpRequired && ssoEnabled && (
<>
<Divider plain style={{ fontSize: 12, color: '#94a3b8' }}>{t('common.or')}</Divider>
<Button
block
icon={<LoginOutlined />}
style={{ marginBottom: 12 }}
onClick={() => { window.location.href = '/api/v1/auth/oidc/login' }}
>
{ssoLabel || t('auth.sso.login')}
</Button>
</>
)}
{!totpRequired && (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>

View File

@@ -46,6 +46,27 @@ interface VIPSettingsValues {
gw_check_ip?: string
}
interface OIDCSettingsView {
enabled: boolean
issuer_url: string
client_id: string
scopes: string
email_claim: string
button_label: string
secret_configured: boolean
redirect_uri: string
}
interface OIDCFormValues {
enabled: boolean
issuer_url: string
client_id: string
client_secret?: string
scopes: string
email_claim: string
button_label: string
}
export default function SettingsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
@@ -93,6 +114,41 @@ export default function SettingsPage() {
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
})
const [oidcForm] = Form.useForm<OIDCFormValues>()
const { data: oidc } = useQuery({
queryKey: ['oidc', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/oidc/settings')
return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null
},
})
useEffect(() => {
if (oidc) {
oidcForm.setFieldsValue({
enabled: oidc.enabled,
issuer_url: oidc.issuer_url,
client_id: oidc.client_id,
scopes: oidc.scopes,
email_claim: oidc.email_claim,
button_label: oidc.button_label,
client_secret: '',
})
}
}, [oidc, oidcForm])
const updateOIDC = useMutation({
mutationFn: async (v: OIDCFormValues) => {
const body: Record<string, unknown> = { ...v }
// leeres Secret = unverändert → Feld weglassen (Backend: nil)
if (!v.client_secret) delete body.client_secret
return apiClient.put('/oidc/settings', body)
},
onSuccess: () => {
msg.success(t('settings.oidc.saved'))
void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] })
},
onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message),
})
const [emailForm] = Form.useForm<ContactEmailValues>()
const updateEmails = useMutation({
mutationFn: async (v: ContactEmailValues) => {
@@ -879,6 +935,49 @@ export default function SettingsPage() {
</Form>
</Card>
<Card title={<><GlobalOutlined /> {t('settings.oidc.title')}</>} className="mb-12" size="small">
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{t('settings.oidc.intro')}
</Typography.Paragraph>
<Form<OIDCFormValues> form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}>
<Form.Item label={t('settings.oidc.enabled')} name="enabled" valuePropName="checked">
<Switch disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.issuerUrl')} name="issuer_url" extra={t('settings.oidc.issuerHint')}>
<Input placeholder="https://keycloak.example.com/realms/edgeguard" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.clientId')} name="client_id">
<Input disabled={isViewer} />
</Form.Item>
<Form.Item
label={t('settings.oidc.clientSecret')}
name="client_secret"
extra={oidc?.secret_configured ? t('settings.oidc.secretSet') : t('settings.oidc.secretUnset')}
>
<Input.Password placeholder={oidc?.secret_configured ? '••••••••' : ''} autoComplete="new-password" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.scopes')} name="scopes">
<Input placeholder="openid email profile" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.emailClaim')} name="email_claim">
<Input placeholder="email" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.buttonLabel')} name="button_label">
<Input disabled={isViewer} />
</Form.Item>
<Form.Item label={t('settings.oidc.redirectUri')} extra={t('settings.oidc.redirectHint')}>
<Typography.Text copyable code style={{ fontSize: 12 }}>{oidc?.redirect_uri || ''}</Typography.Text>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button type="primary" htmlType="submit" loading={updateOIDC.isPending} disabled={isViewer}>
{t('common.save')}
</Button>
</Tooltip>
</Form.Item>
</Form>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues>
form={pwForm}