feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak, Authentik, …) alongside local accounts. Resolves the feature request in #73. Flow (backend-driven Authorization Code + PKCE): - GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce transaction in Redis (single-use, 10m TTL); the callback validates the ID token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the identity to a local user, and mints the platform's normal session JWT — handed to the SPA via URL fragment. No sessions/cookies introduced. Identity mapping (users table gains email/oidc_subject/oidc_issuer/ auth_provider; password_hash now nullable; partial unique indexes): - First sign-in auto-provisions a user, or links to an existing local account when the IdP email is verified and matches. - Roles derive from a configurable IdP group -> role map with a default fallback; existing users are never auto-demoted on a groupless login. Configuration (admin-only, no env/redeploy needed): - Stored as an app_settings row with the client secret encrypted at rest (utils/crypto); masked in responses and excluded from GET /api/settings. - New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role mapping, provisioning toggles, discovery test, copyable redirect URI), a login-page SSO button, and an SSO badge in Users & Roles. Security: local break-glass login preserved; PKCE + single-use state; email linking requires email_verified; same-site-only post-login redirect. Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping, username derivation, config secret round-trip/masking); 114 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ea1ad956ff
commit
c0f230bdf2
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap,
|
||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn,
|
||||
} from 'lucide-react';
|
||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
||||
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import clsx from 'clsx';
|
||||
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
|
||||
import AutomationSettings from '../components/settings/AutomationSettings';
|
||||
import OidcSettings from '../components/settings/OidcSettings';
|
||||
|
||||
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
|
||||
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
|
||||
@@ -48,7 +49,7 @@ export default function SettingsPage() {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const canWrite = user?.role !== 'viewer';
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
|
||||
const [auditSearch, setAuditSearch] = useState('');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
@@ -472,6 +473,7 @@ export default function SettingsPage() {
|
||||
tabs: [
|
||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||
...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []),
|
||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||
],
|
||||
@@ -872,9 +874,17 @@ export default function SettingsPage() {
|
||||
{(users as User[]).map((u) => (
|
||||
<tr key={u.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
{u.username}
|
||||
{u.id === user?.id && (
|
||||
<span className="ml-2 text-xs text-blue-500">(you)</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{u.username}
|
||||
{u.id === user?.id && (
|
||||
<span className="text-xs text-blue-500">(you)</span>
|
||||
)}
|
||||
{u.auth_provider === 'oidc' && (
|
||||
<span className="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">SSO</span>
|
||||
)}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500 font-normal">{u.email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
@@ -1806,6 +1816,9 @@ export default function SettingsPage() {
|
||||
{activeTab === 'automation' && (
|
||||
<AutomationSettings isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeTab === 'sso' && (
|
||||
<OidcSettings isAdmin={isAdmin} />
|
||||
)}
|
||||
{/* ── Config Templates ── */}
|
||||
{activeTab === 'templates' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user