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

@@ -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}