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
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { KeyRound, Plus, Trash2, Copy, Check, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { oidcApi, type OidcConfigView } from '../../services/api';
import type { UserRole } from '../../types';
const ROLES: UserRole[] = ['viewer', 'operator', 'admin'];
type GroupRow = { group: string; role: UserRole };
export default function OidcSettings({ isAdmin }: { isAdmin: boolean }) {
const { data } = useQuery({
queryKey: ['oidc-config'],
queryFn: () => oidcApi.getConfig().then((r) => r.data),
enabled: isAdmin,
});
if (!isAdmin) {
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Single sign-on can only be configured by administrators.</div>;
}
if (!data) return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">Loading…</div>;
// Remount when the loaded config identity changes so form state re-initializes.
return <OidcForm key={data.redirect_uri} initial={data} />;
}
function OidcForm({ initial }: { initial: OidcConfigView }) {
const qc = useQueryClient();
const [form, setForm] = useState<OidcConfigView>(initial);
const [secret, setSecret] = useState('');
const [groupRows, setGroupRows] = useState<GroupRow[]>(
Object.entries(initial.group_role_map || {}).map(([group, role]) => ({ group, role }))
);
const [domains, setDomains] = useState((initial.allowed_email_domains || []).join(', '));
const [copied, setCopied] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
const [saved, setSaved] = useState(false);
const save = useMutation({
mutationFn: () => {
const group_role_map: Record<string, UserRole> = {};
for (const r of groupRows) if (r.group.trim()) group_role_map[r.group.trim()] = r.role;
const allowed_email_domains = domains.split(',').map((d) => d.trim()).filter(Boolean);
const payload: Partial<OidcConfigView> & { client_secret?: string } = {
...form!,
group_role_map,
allowed_email_domains,
};
delete (payload as Record<string, unknown>).redirect_uri;
delete (payload as Record<string, unknown>).has_secret;
if (secret) payload.client_secret = secret;
return oidcApi.updateConfig(payload);
},
onSuccess: (r) => {
setSecret('');
setSaved(true);
setTimeout(() => setSaved(false), 2500);
qc.setQueryData(['oidc-config'], r.data);
},
});
const test = useMutation({
mutationFn: () => oidcApi.test(form.issuer_url),
onSuccess: (r) => setTestResult({ ok: r.data.ok, msg: r.data.ok ? `Discovered ${r.data.issuer}` : (r.data.error || 'Failed') }),
onError: (e: unknown) => setTestResult({ ok: false, msg: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Discovery failed' }),
});
const set = <K extends keyof OidcConfigView>(k: K, v: OidcConfigView[K]) => setForm({ ...form, [k]: v });
const copyRedirect = () => {
navigator.clipboard.writeText(form.redirect_uri).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
};
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Single Sign-On (OIDC)</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">authenticate against your identity provider</span>
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} />
Enabled
</label>
</div>
<div className="p-5 space-y-5">
{/* Redirect URI to register with the IdP */}
<div>
<label className="label">Redirect URI (register this with your provider)</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1.5 rounded bg-gray-50 dark:bg-slate-800 font-mono text-xs break-all text-gray-700 dark:text-slate-300">{form.redirect_uri}</code>
<button className="btn-secondary flex items-center gap-1.5" onClick={copyRedirect}>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
{/* Provider connection */}
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="label">Issuer URL</label>
<input className="input w-full" value={form.issuer_url} placeholder="https://login.example.com/realms/main" onChange={(e) => set('issuer_url', e.target.value)} />
</div>
<div>
<label className="label">Client ID</label>
<input className="input w-full" value={form.client_id} onChange={(e) => set('client_id', e.target.value)} />
</div>
<div>
<label className="label">Client Secret {form.has_secret && <span className="text-green-600 dark:text-green-400">(configured)</span>}</label>
<input className="input w-full" type="password" value={secret} placeholder={form.has_secret ? '•••••••• (leave blank to keep)' : 'none — public client'} onChange={(e) => setSecret(e.target.value)} />
</div>
<div>
<label className="label">Scopes</label>
<input className="input w-full" value={form.scopes} onChange={(e) => set('scopes', e.target.value)} />
</div>
<div>
<label className="label">Button label</label>
<input className="input w-full" value={form.button_label} onChange={(e) => set('button_label', e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2">
<button className="btn-secondary flex items-center gap-1.5" disabled={!form.issuer_url || test.isPending} onClick={() => test.mutate()}>
<RefreshCw className={test.isPending ? 'w-4 h-4 animate-spin' : 'w-4 h-4'} />Test discovery
</button>
{testResult && (
<span className={`flex items-center gap-1.5 text-xs ${testResult.ok ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{testResult.ok ? <CheckCircle2 className="w-3.5 h-3.5" /> : <AlertTriangle className="w-3.5 h-3.5" />}{testResult.msg}
</span>
)}
</div>
{/* Claim mapping */}
<div className="grid gap-3 sm:grid-cols-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<div>
<label className="label">Username claim</label>
<input className="input w-full" value={form.username_claim} onChange={(e) => set('username_claim', e.target.value)} />
</div>
<div>
<label className="label">Email claim</label>
<input className="input w-full" value={form.email_claim} onChange={(e) => set('email_claim', e.target.value)} />
</div>
<div>
<label className="label">Groups claim</label>
<input className="input w-full" value={form.groups_claim} onChange={(e) => set('groups_claim', e.target.value)} />
</div>
</div>
{/* Group → role mapping */}
<div>
<label className="label">Group → role mapping</label>
<div className="space-y-2">
{groupRows.map((row, i) => (
<div key={i} className="flex items-center gap-2">
<input className="input flex-1" placeholder="IdP group name" value={row.group} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, group: e.target.value } : r))} />
<span className="text-gray-400">→</span>
<select className="input w-36" value={row.role} onChange={(e) => setGroupRows(groupRows.map((r, j) => j === i ? { ...r, role: e.target.value as UserRole } : r))}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button className="p-1.5 text-gray-400 hover:text-red-600" onClick={() => setGroupRows(groupRows.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
))}
<button className="btn-secondary flex items-center gap-1.5" onClick={() => setGroupRows([...groupRows, { group: '', role: 'viewer' }])}>
<Plus className="w-4 h-4" />Add mapping
</button>
</div>
<div className="mt-2 flex items-center gap-2">
<label className="label mb-0">Default role (no group match)</label>
<select className="input w-36" value={form.default_role} onChange={(e) => set('default_role', e.target.value as UserRole)}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
</div>
{/* Provisioning */}
<div className="grid gap-2 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.auto_provision} onChange={(e) => set('auto_provision', e.target.checked)} />
Auto-provision new users on first sign-in
</label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" checked={form.link_by_verified_email} onChange={(e) => set('link_by_verified_email', e.target.checked)} />
Link to existing accounts by verified email
</label>
<div>
<label className="label">Allowed email domains (comma-separated, blank = any)</label>
<input className="input w-full" value={domains} placeholder="example.com, corp.example.com" onChange={(e) => setDomains(e.target.value)} />
</div>
<div>
<label className="label">Public base URL (optional — overrides the redirect URI host)</label>
<input className="input w-full" value={form.public_base_url} placeholder="https://manager.example.com" onChange={(e) => set('public_base_url', e.target.value)} />
</div>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-slate-700/50">
<button className="btn-primary flex items-center gap-1.5" disabled={save.isPending} onClick={() => save.mutate()}>
{save.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}Save
</button>
{saved && <span className="text-xs text-green-600 dark:text-green-400">Saved</span>}
{save.isError && <span className="text-xs text-red-600 dark:text-red-400">{(save.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Save failed'}</span>}
</div>
</div>
</div>
);
}