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
+2
View File
@@ -11,6 +11,7 @@ import TopologyPage from './pages/TopologyPage';
import BackupsPage from './pages/BackupsPage';
import FirmwarePage from './pages/FirmwarePage';
import SettingsPage from './pages/SettingsPage';
import OidcCallbackPage from './pages/OidcCallbackPage';
import WirelessPage from './pages/WirelessPage';
import WirelessSettingsPage from './pages/WirelessSettingsPage';
import GuestWifiPage from './pages/GuestWifiPage';
@@ -37,6 +38,7 @@ export default function App() {
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<OidcCallbackPage />} />
<Route
path="/"
element={
@@ -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>
);
}
+36 -2
View File
@@ -1,6 +1,6 @@
import { useState, FormEvent, useRef } from 'react';
import { useState, FormEvent, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Network, Eye, EyeOff, AlertCircle, ShieldCheck } from 'lucide-react';
import { Network, Eye, EyeOff, AlertCircle, ShieldCheck, LogIn } from 'lucide-react';
import { authApi } from '../services/api';
import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
@@ -16,10 +16,22 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState('');
const totpInputRef = useRef<HTMLInputElement>(null);
const [sso, setSso] = useState<{ enabled: boolean; button_label: string }>({ enabled: false, button_label: 'Sign in with SSO' });
const setAuth = useAuthStore((s) => s.setAuth);
const navigate = useNavigate();
const { theme, toggleTheme } = useThemeStore();
useEffect(() => {
authApi.oidcStatus().then((r) => setSso(r.data)).catch(() => {});
// Surface an SSO error passed back on the redirect (?error=sso&reason=...).
const params = new URLSearchParams(window.location.search);
if (params.get('error') === 'sso') {
setError(params.get('reason') || 'Single sign-on failed. Please try again.');
window.history.replaceState({}, '', window.location.pathname);
}
}, []);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -234,6 +246,28 @@ export default function LoginPage() {
totpToken ? 'Verify Code' : 'Sign in'
)}
</button>
{sso.enabled && !totpToken && (
<>
<div className="flex items-center gap-3 my-1">
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>or</span>
<div className={`h-px flex-1 ${isDark ? 'bg-slate-700' : 'bg-slate-200'}`} />
</div>
<button
type="button"
onClick={() => { window.location.href = '/api/auth/oidc/login'; }}
className={`w-full flex items-center justify-center gap-2 px-4 py-2 font-medium rounded-lg transition-colors border ${
isDark
? 'bg-slate-800/80 border-slate-600 text-white hover:bg-slate-700'
: 'bg-white/80 border-slate-300 text-slate-800 hover:bg-slate-50'
}`}
>
<LogIn className="w-4 h-4" />
{sso.button_label || 'Sign in with SSO'}
</button>
</>
)}
</form>
</div>
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../services/api';
import { useAuthStore } from '../store/authStore';
import type { User, UserRole } from '../types';
/**
* Landing route for the OIDC redirect. The backend hands us the session JWT in
* the URL fragment (never sent to the server); we hydrate the auth store and go
* to the dashboard.
*/
export default function OidcCallbackPage() {
const setAuth = useAuthStore((s) => s.setAuth);
const navigate = useNavigate();
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const token = hash.get('token');
// Same-site relative paths only (reject absolute / protocol-relative).
const rawReturn = hash.get('returnTo') || '/dashboard';
const returnTo = /^\/(?!\/)[^\\]*$/.test(rawReturn) ? rawReturn : '/dashboard';
if (!token) {
navigate('/login?error=sso&reason=' + encodeURIComponent('No token returned'), { replace: true });
return;
}
// Persist the token first so the /me request is authenticated.
setAuth(token, { id: 0, username: '', role: 'viewer' });
authApi.me()
.then((r) => {
const u = (r.data as { user: { userId?: number; id?: number; username: string; role: UserRole } }).user;
const user: User = { id: u.id ?? u.userId ?? 0, username: u.username, role: u.role };
setAuth(token, user);
navigate(returnTo.startsWith('/') ? returnTo : '/dashboard', { replace: true });
})
.catch(() => {
useAuthStore.getState().logout();
navigate('/login?error=sso&reason=' + encodeURIComponent('Could not load your account'), { replace: true });
});
}, [navigate, setAuth]);
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-slate-500">
<span className="inline-block w-5 h-5 border-2 border-slate-400/30 border-t-slate-400 rounded-full animate-spin" />
Signing you in…
</div>
</div>
);
}
+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">
+33
View File
@@ -21,6 +21,7 @@ import type {
ConfigSnapshotFull,
ConfigDiffResponse,
ConfigCaptureResult,
UserRole,
} from '../types';
const api = axios.create({
@@ -66,6 +67,38 @@ export const authApi = {
api.put('/auth/password', { currentPassword, newPassword }),
securityStatus: () =>
api.get<{ warnings: string[] }>('/auth/security-status'),
oidcStatus: () =>
api.get<{ enabled: boolean; button_label: string }>('/auth/oidc/status'),
};
export interface OidcConfigView {
enabled: boolean;
issuer_url: string;
client_id: string;
has_secret: boolean;
scopes: string;
username_claim: string;
email_claim: string;
groups_claim: string;
group_role_map: Record<string, UserRole>;
default_role: UserRole;
auto_provision: boolean;
link_by_verified_email: boolean;
allowed_email_domains: string[];
button_label: string;
public_base_url: string;
redirect_uri: string;
}
export const oidcApi = {
getConfig: () => api.get<OidcConfigView>('/auth/oidc/config'),
updateConfig: (data: Partial<OidcConfigView> & { client_secret?: string }) =>
api.put<OidcConfigView>('/auth/oidc/config', data),
test: (issuer_url?: string) =>
api.post<{ ok: boolean; issuer?: string; claims_supported?: string[]; error?: string }>(
'/auth/oidc/test',
{ issuer_url }
),
};
// ─── Devices ──────────────────────────────────────────────────────────────────
+2
View File
@@ -3,7 +3,9 @@ export type UserRole = 'admin' | 'operator' | 'viewer';
export interface User {
id: number;
username: string;
email?: string | null;
role: UserRole;
auth_provider?: 'local' | 'oidc';
created_at?: string;
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.12 Beta';
export const APP_VERSION = 'v0.17.0 Beta';