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:
2GT-Rich
2026-07-16 18:54:36 -07:00
co-authored by Claude Opus 4.8
parent ea1ad956ff
commit c0f230bdf2
21 changed files with 1056 additions and 14 deletions
+18 -5
View File
@@ -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">