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
+10
View File
@@ -453,6 +453,16 @@ CREATE TABLE IF NOT EXISTS config_templates (
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64);
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
-- OIDC / SSO identity mapping. Local users keep password_hash; SSO users are
-- keyed by (oidc_issuer, oidc_subject) and have a null password_hash.
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(255);
ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(20) NOT NULL DEFAULT 'local';
ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_oidc ON users (oidc_issuer, oidc_subject) WHERE oidc_subject IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_lower ON users (lower(email)) WHERE email IS NOT NULL;
-- Per-client daily traffic rollups from the NetFlow collector. mac_address
-- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other'
-- (clients folded by the top-N cardinality cap).
+2
View File
@@ -32,6 +32,7 @@ import { decrypt } from './utils/crypto';
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
import authRoutes from './routes/auth';
import oidcRoutes from './routes/oidc';
import auditLogRoutes from './routes/auditLog';
import tagsRoutes from './routes/tags';
import maintenanceWindowsRoutes from './routes/maintenanceWindows';
@@ -296,6 +297,7 @@ app.get('/api/health', (_req, res) => {
});
// ─── Routes ───────────────────────────────────────────────────────────────────
app.use('/api/auth/oidc', oidcRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/devices', devicesRoutes);
app.use('/api/clients', clientsRoutes);
+134
View File
@@ -0,0 +1,134 @@
/**
* OIDC / SSO endpoints, mounted at /api/auth/oidc.
*
* Public: GET /status, GET /login, GET /callback (the browser-facing flow).
* Admin: GET/PUT /config, POST /test (provider configuration).
*
* The callback mints the platform's normal session JWT and hands it to the SPA
* via the URL fragment, matching the existing localStorage/Bearer model.
*/
import { Router, Request, Response } from 'express';
import { Issuer } from 'openid-client';
import { requireAuth, requireAdmin, signToken } from '../middleware/auth';
import { rateLimitRedis } from '../middleware/rateLimitRedis';
import {
loadOidcConfig, saveOidcConfig, maskedConfig, APP_ROLES, type AppRole, type OidcConfig,
} from '../services/oidc/oidcConfig';
import { beginLogin, completeLogin, resetOidcClientCache } from '../services/oidc/OidcService';
const router = Router();
function redirectUriFor(req: Request, config: OidcConfig): string {
const base = config.public_base_url
? config.public_base_url.replace(/\/$/, '')
: `${req.protocol}://${req.get('host')}`;
return `${base}/api/auth/oidc/callback`;
}
// Only accept same-site relative paths for post-login navigation. Rejects
// absolute URLs and protocol-relative values (//host) to avoid open redirects.
function safeReturnTo(value: unknown): string {
return typeof value === 'string' && /^\/(?!\/)[^\\\s]*$/.test(value) ? value : '/dashboard';
}
// ─── Public: is SSO available? (drives the login button) ────────────────────────
router.get('/status', async (_req: Request, res: Response) => {
const config = await loadOidcConfig();
res.json({
enabled: !!(config.enabled && config.issuer_url && config.client_id),
button_label: config.button_label || 'Sign in with SSO',
});
});
// ─── Public: start the login flow ───────────────────────────────────────────────
router.get(
'/login',
rateLimitRedis({ windowSec: 60, max: 20, keyPrefix: 'oidc-login', allMethods: true }),
async (req: Request, res: Response) => {
try {
const config = await loadOidcConfig();
const returnTo = safeReturnTo(req.query.returnTo);
const url = await beginLogin(redirectUriFor(req, config), returnTo);
res.redirect(url);
} catch (e) {
res.redirect(`/login?error=sso&reason=${encodeURIComponent((e as Error).message)}`);
}
}
);
// ─── Public: IdP redirect target ────────────────────────────────────────────────
router.get(
'/callback',
rateLimitRedis({ windowSec: 60, max: 30, keyPrefix: 'oidc-callback', allMethods: true }),
async (req: Request, res: Response) => {
try {
const params = req.query as Record<string, string>;
if (params.error) throw new Error(params.error_description || params.error);
const { user, returnTo } = await completeLogin(params);
const token = signToken({ userId: user.id, username: user.username, role: user.role });
const dest = safeReturnTo(returnTo);
// Hand the token to the SPA via fragment (never sent to the server/logs).
res.redirect(`/auth/callback#token=${encodeURIComponent(token)}&returnTo=${encodeURIComponent(dest)}`);
} catch (e) {
res.redirect(`/login?error=sso&reason=${encodeURIComponent((e as Error).message)}`);
}
}
);
// ─── Admin: read config (secret masked) ─────────────────────────────────────────
router.get('/config', requireAuth, requireAdmin, async (req: Request, res: Response) => {
const config = await loadOidcConfig();
res.json({ ...maskedConfig(config), redirect_uri: redirectUriFor(req, config) });
});
// ─── Admin: update config ───────────────────────────────────────────────────────
router.put('/config', requireAuth, requireAdmin, async (req: Request, res: Response) => {
const body = req.body as Partial<OidcConfig> & { client_secret?: string };
const validRole = (r: unknown): r is AppRole => APP_ROLES.includes(r as AppRole);
if (body.default_role !== undefined && !validRole(body.default_role)) {
return res.status(400).json({ error: 'Invalid default_role' });
}
if (body.group_role_map !== undefined) {
if (typeof body.group_role_map !== 'object' || body.group_role_map === null) {
return res.status(400).json({ error: 'group_role_map must be an object' });
}
for (const r of Object.values(body.group_role_map)) {
if (!validRole(r)) return res.status(400).json({ error: `Invalid role in group_role_map: ${String(r)}` });
}
}
if (body.enabled && body.issuer_url !== undefined) {
try { new URL(body.issuer_url); } catch { return res.status(400).json({ error: 'issuer_url must be a valid URL' }); }
}
// client_secret handling: undefined = keep, '' = clear, string = set.
const { client_secret, ...patch } = body;
delete (patch as Record<string, unknown>).client_secret_encrypted; // never accept ciphertext from the client
delete (patch as Record<string, unknown>).has_secret;
const saved = await saveOidcConfig(patch, client_secret);
resetOidcClientCache();
res.json({ ...maskedConfig(saved), redirect_uri: redirectUriFor(req, saved) });
});
// ─── Admin: validate discovery against the configured issuer ────────────────────
router.post('/test', requireAuth, requireAdmin, async (req: Request, res: Response) => {
const issuerUrl = (req.body?.issuer_url as string) || (await loadOidcConfig()).issuer_url;
if (!issuerUrl) return res.status(400).json({ error: 'issuer_url is required' });
try {
const issuer = await Issuer.discover(issuerUrl);
res.json({
ok: true,
issuer: issuer.metadata.issuer,
authorization_endpoint: issuer.metadata.authorization_endpoint,
token_endpoint: issuer.metadata.token_endpoint,
userinfo_endpoint: issuer.metadata.userinfo_endpoint,
scopes_supported: issuer.metadata.scopes_supported,
claims_supported: issuer.metadata.claims_supported,
});
} catch (e) {
res.status(400).json({ ok: false, error: `Discovery failed: ${(e as Error).message}` });
}
});
export default router;
+9 -1
View File
@@ -4,15 +4,22 @@ import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin } from '../middleware/auth';
import { netflowCollector } from '../services/netflow/NetflowCollector';
import { validatePassword } from '../utils/passwordPolicy';
import { OIDC_SETTINGS_KEY } from '../services/oidc/oidcConfig';
const router = Router();
router.use(requireAuth);
// Settings keys managed through their own dedicated (admin-only) endpoints and
// never exposed via this generic surface — the OIDC config holds an encrypted
// client secret and must only be read/written through /api/auth/oidc/config.
const PROTECTED_SETTING_KEYS = new Set<string>([OIDC_SETTINGS_KEY]);
// GET /api/settings
router.get('/', async (_req: Request, res: Response) => {
const settings = await query(`SELECT key, value FROM app_settings ORDER BY key`);
const map: Record<string, unknown> = {};
for (const s of settings as { key: string; value: unknown }[]) {
if (PROTECTED_SETTING_KEYS.has(s.key)) continue;
map[s.key] = s.value;
}
res.json(map);
@@ -22,6 +29,7 @@ router.get('/', async (_req: Request, res: Response) => {
router.put('/', requireAdmin, async (req: Request, res: Response) => {
const updates = req.body as Record<string, unknown>;
for (const [key, value] of Object.entries(updates)) {
if (PROTECTED_SETTING_KEYS.has(key)) continue;
await query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
@@ -39,7 +47,7 @@ router.put('/', requireAdmin, async (req: Request, res: Response) => {
// GET /api/settings/users
router.get('/users', requireAdmin, async (_req: Request, res: Response) => {
const users = await query(
`SELECT id, username, role, created_at FROM users ORDER BY username`
`SELECT id, username, email, role, auth_provider, created_at FROM users ORDER BY username`
);
res.json(users);
});
+193
View File
@@ -0,0 +1,193 @@
/**
* OIDC Authorization-Code (PKCE) flow orchestration.
*
* beginLogin() builds the authorize URL and stashes the per-request PKCE/nonce
* transaction in Redis (short TTL, single-use). completeLogin() validates the
* callback (openid-client checks the ID-token signature via JWKS plus iss/aud/
* exp/nonce and the PKCE verifier), then resolves the identity to a local user.
*/
import { Issuer, generators } from 'openid-client';
import type { Client, TokenSet } from 'openid-client';
import { createHash } from 'crypto';
import { redis } from '../../config/redis';
import { query, queryOne } from '../../config/database';
import { loadOidcConfig, getClientSecret, type OidcConfig, type AppRole } from './oidcConfig';
import {
extractGroups, mapGroupsToRole, deriveUsername, getEmail, isEmailVerified, emailDomainAllowed,
} from './claimMapping';
const TX_TTL_SECONDS = 600; // 10 minutes
const txKey = (state: string): string => `oidc:tx:${state}`;
export interface ResolvedUser {
id: number;
username: string;
role: AppRole;
}
interface Transaction {
nonce: string;
code_verifier: string;
redirectUri: string;
returnTo: string;
}
// Cache the discovered client; rebuild when the relevant config or redirect URI changes.
let clientCache: { key: string; client: Client } | null = null;
function configFingerprint(config: OidcConfig, redirectUri: string): string {
return createHash('sha256')
.update([config.issuer_url, config.client_id, config.client_secret_encrypted ?? '', redirectUri].join('|'))
.digest('hex');
}
async function getClient(config: OidcConfig, redirectUri: string): Promise<Client> {
if (!config.issuer_url || !config.client_id) throw new Error('OIDC is not fully configured');
const key = configFingerprint(config, redirectUri);
if (clientCache && clientCache.key === key) return clientCache.client;
const issuer = await Issuer.discover(config.issuer_url);
const secret = getClientSecret(config);
const client = new issuer.Client({
client_id: config.client_id,
client_secret: secret ?? undefined,
redirect_uris: [redirectUri],
response_types: ['code'],
// Public client (no secret) must use PKCE-only token auth.
token_endpoint_auth_method: secret ? 'client_secret_basic' : 'none',
});
clientCache = { key, client };
return client;
}
/** Invalidate the cached client after a config change. */
export function resetOidcClientCache(): void {
clientCache = null;
}
export async function beginLogin(redirectUri: string, returnTo: string): Promise<string> {
const config = await loadOidcConfig();
if (!config.enabled) throw new Error('OIDC login is not enabled');
const client = await getClient(config, redirectUri);
const state = generators.state();
const nonce = generators.nonce();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const tx: Transaction = { nonce, code_verifier, redirectUri, returnTo };
await redis.set(txKey(state), JSON.stringify(tx), 'EX', TX_TTL_SECONDS);
return client.authorizationUrl({
scope: config.scopes || 'openid profile email',
state,
nonce,
code_challenge,
code_challenge_method: 'S256',
});
}
export async function completeLogin(params: Record<string, string>): Promise<{ user: ResolvedUser; returnTo: string }> {
const state = params.state;
if (!state) throw new Error('Missing state');
const raw = await redis.get(txKey(state));
if (!raw) throw new Error('Login session expired or invalid — please try again');
await redis.del(txKey(state)); // single use
const tx: Transaction = JSON.parse(raw);
const config = await loadOidcConfig();
const client = await getClient(config, tx.redirectUri);
const tokenSet: TokenSet = await client.callback(tx.redirectUri, params, {
code_verifier: tx.code_verifier,
state,
nonce: tx.nonce,
});
let claims: Record<string, unknown> = { ...tokenSet.claims() };
// Some IdPs put groups only in the userinfo response, not the ID token.
if (!claims[config.groups_claim] && tokenSet.access_token) {
try {
const userinfo = await client.userinfo(tokenSet);
claims = { ...claims, ...userinfo };
} catch { /* userinfo optional */ }
}
const user = await resolveUser(claims, config);
return { user, returnTo: tx.returnTo };
}
async function resolveUser(claims: Record<string, unknown>, config: OidcConfig): Promise<ResolvedUser> {
const sub = String(claims.sub ?? '');
const issuer = String(claims.iss ?? '');
if (!sub || !issuer) throw new Error('ID token missing sub/iss');
const email = getEmail(claims, config);
const mappedRole = mapGroupsToRole(extractGroups(claims, config.groups_claim), config.group_role_map);
if (!emailDomainAllowed(email, config.allowed_email_domains)) {
throw new Error('Your email domain is not permitted to sign in');
}
// 1. Already-linked user → authenticate, sync role only when a group maps
// (never auto-demote to the default when no group matches).
const linked = await queryOne<{ id: number; username: string; role: AppRole }>(
`SELECT id, username, role FROM users WHERE oidc_issuer = $1 AND oidc_subject = $2`,
[issuer, sub]
);
if (linked) {
const role = mappedRole ?? linked.role;
if (role !== linked.role) {
await query(`UPDATE users SET role = $1 WHERE id = $2`, [role, linked.id]);
}
if (email) await query(`UPDATE users SET email = $1 WHERE id = $2`, [email, linked.id]).catch(() => {});
return { id: linked.id, username: linked.username, role };
}
// 2. Link to an existing local account by verified email
if (config.link_by_verified_email && email && isEmailVerified(claims)) {
const byEmail = await queryOne<{ id: number; username: string; role: AppRole }>(
`SELECT id, username, role FROM users WHERE lower(email) = lower($1) AND oidc_subject IS NULL`,
[email]
);
if (byEmail) {
const role = mappedRole ?? byEmail.role; // preserve existing role if no group maps
await query(
`UPDATE users SET oidc_issuer = $1, oidc_subject = $2, auth_provider = 'oidc', role = $3 WHERE id = $4`,
[issuer, sub, role, byEmail.id]
);
return { id: byEmail.id, username: byEmail.username, role };
}
}
// 3. Auto-provision a new SSO user
if (config.auto_provision) {
const role = mappedRole ?? config.default_role;
const username = await uniqueUsername(deriveUsername(claims, config));
const created = await queryOne<{ id: number; username: string; role: AppRole }>(
`INSERT INTO users (username, email, role, auth_provider, oidc_issuer, oidc_subject)
VALUES ($1, $2, $3, 'oidc', $4, $5)
RETURNING id, username, role`,
[username, email, role, issuer, sub]
);
if (!created) throw new Error('Failed to provision user');
return created;
}
// 4. No account and provisioning disabled
throw new Error('No account exists for this identity, and automatic provisioning is disabled');
}
async function uniqueUsername(base: string): Promise<string> {
const root = (base || 'user').slice(0, 50);
const existing = await queryOne(`SELECT 1 FROM users WHERE username = $1`, [root]);
if (!existing) return root;
for (let i = 2; i <= 50; i++) {
const suffix = `-${i}`;
const candidate = root.slice(0, 50 - suffix.length) + suffix;
const clash = await queryOne(`SELECT 1 FROM users WHERE username = $1`, [candidate]);
if (!clash) return candidate;
}
return `${root.slice(0, 40)}-${Date.now().toString().slice(-6)}`;
}
@@ -0,0 +1,71 @@
import {
extractGroups, mapGroupsToRole, deriveUsername, getEmail, isEmailVerified, emailDomainAllowed,
} from '../claimMapping';
import { DEFAULT_OIDC_CONFIG, type AppRole } from '../oidcConfig';
const cfg = (over = {}) => ({ ...DEFAULT_OIDC_CONFIG, ...over });
describe('extractGroups', () => {
it('reads an array claim', () => {
expect(extractGroups({ groups: ['a', 'b'] }, 'groups')).toEqual(['a', 'b']);
});
it('splits a space/comma-delimited string claim', () => {
expect(extractGroups({ roles: 'admin, netops ops' }, 'roles')).toEqual(['admin', 'netops', 'ops']);
});
it('returns [] when the claim is missing', () => {
expect(extractGroups({}, 'groups')).toEqual([]);
});
});
describe('mapGroupsToRole', () => {
const map: Record<string, AppRole> = { admins: 'admin', ops: 'operator', staff: 'viewer' };
it('picks the highest-privilege matching role', () => {
expect(mapGroupsToRole(['staff', 'ops', 'admins'], map)).toBe('admin');
expect(mapGroupsToRole(['staff', 'ops'], map)).toBe('operator');
});
it('returns null when no group matches', () => {
expect(mapGroupsToRole(['unknown'], map)).toBeNull();
expect(mapGroupsToRole([], map)).toBeNull();
});
});
describe('deriveUsername', () => {
it('prefers the configured username claim', () => {
expect(deriveUsername({ preferred_username: 'jdoe', email: 'j@x.io', sub: 'abc' }, cfg())).toBe('jdoe');
});
it('falls back to the email local-part', () => {
expect(deriveUsername({ email: 'jane.doe@x.io', sub: 'abc' }, cfg())).toBe('jane.doe');
});
it('falls back to sub', () => {
expect(deriveUsername({ sub: 'sub-123' }, cfg())).toBe('sub-123');
});
it('clamps to 50 chars', () => {
expect(deriveUsername({ preferred_username: 'x'.repeat(80) }, cfg()).length).toBe(50);
});
});
describe('getEmail / isEmailVerified', () => {
it('extracts a valid email', () => {
expect(getEmail({ email: 'a@b.com' }, cfg())).toBe('a@b.com');
expect(getEmail({ email: 'not-an-email' }, cfg())).toBeNull();
expect(getEmail({}, cfg())).toBeNull();
});
it('reads email_verified as boolean or string', () => {
expect(isEmailVerified({ email_verified: true })).toBe(true);
expect(isEmailVerified({ email_verified: 'true' })).toBe(true);
expect(isEmailVerified({ email_verified: false })).toBe(false);
expect(isEmailVerified({})).toBe(false);
});
});
describe('emailDomainAllowed', () => {
it('allows any domain when the allowlist is empty', () => {
expect(emailDomainAllowed('a@anywhere.io', [])).toBe(true);
});
it('enforces the allowlist (case-insensitive, tolerates leading @)', () => {
expect(emailDomainAllowed('a@corp.com', ['corp.com'])).toBe(true);
expect(emailDomainAllowed('a@CORP.com', ['@corp.com'])).toBe(true);
expect(emailDomainAllowed('a@evil.com', ['corp.com'])).toBe(false);
expect(emailDomainAllowed(null, ['corp.com'])).toBe(false);
});
});
@@ -0,0 +1,25 @@
import { encrypt } from '../../../utils/crypto';
import { getClientSecret, maskedConfig, DEFAULT_OIDC_CONFIG, type OidcConfig } from '../oidcConfig';
describe('OIDC config secret handling', () => {
it('decrypts a stored client secret', () => {
const config: OidcConfig = { ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: encrypt('super-secret') };
expect(getClientSecret(config)).toBe('super-secret');
});
it('returns null when no secret is stored', () => {
expect(getClientSecret({ ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: null })).toBeNull();
});
it('masks the secret and never exposes ciphertext', () => {
const config: OidcConfig = { ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: encrypt('x') };
const masked = maskedConfig(config);
expect(masked.has_secret).toBe(true);
expect('client_secret_encrypted' in masked).toBe(false);
expect(JSON.stringify(masked)).not.toContain('client_secret_encrypted');
});
it('reports has_secret=false when unset', () => {
expect(maskedConfig({ ...DEFAULT_OIDC_CONFIG, client_secret_encrypted: null }).has_secret).toBe(false);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Pure helpers that map OIDC ID-token / userinfo claims onto the platform's user
* fields. Kept free of DB/network so they can be unit-tested in isolation.
*/
import type { AppRole, OidcConfig } from './oidcConfig';
const ROLE_RANK: Record<AppRole, number> = { viewer: 0, operator: 1, admin: 2 };
type Claims = Record<string, unknown>;
/** Extract the groups claim as a string[] (handles array or space/comma string). */
export function extractGroups(claims: Claims, groupsClaim: string): string[] {
const raw = claims[groupsClaim];
if (Array.isArray(raw)) return raw.map((g) => String(g));
if (typeof raw === 'string') return raw.split(/[\s,]+/).filter(Boolean);
return [];
}
/**
* Highest-privilege role among the user's groups, or null if none of their
* groups are mapped (caller decides the fallback).
*/
export function mapGroupsToRole(groups: string[], map: Record<string, AppRole>): AppRole | null {
let best: AppRole | null = null;
for (const g of groups) {
const r = map[g];
if (r && (best === null || ROLE_RANK[r] > ROLE_RANK[best])) best = r;
}
return best;
}
/** Derive a base username from the configured claim, falling back to email local-part then sub. */
export function deriveUsername(claims: Claims, config: OidcConfig): string {
const fromClaim = claims[config.username_claim];
if (typeof fromClaim === 'string' && fromClaim.trim()) return clamp(fromClaim.trim());
const email = claims[config.email_claim];
if (typeof email === 'string' && email.includes('@')) return clamp(email.split('@')[0]);
return clamp(String(claims.sub ?? 'user'));
}
/** username column is VARCHAR(50). */
function clamp(s: string): string {
return s.slice(0, 50);
}
export function getEmail(claims: Claims, config: OidcConfig): string | null {
const v = claims[config.email_claim] ?? claims.email;
return typeof v === 'string' && v.includes('@') ? v : null;
}
export function isEmailVerified(claims: Claims): boolean {
return claims.email_verified === true || claims.email_verified === 'true';
}
/** True if no allowlist is configured, or the email's domain is on it. */
export function emailDomainAllowed(email: string | null, allowed: string[]): boolean {
if (!allowed || allowed.length === 0) return true;
if (!email || !email.includes('@')) return false;
const domain = email.split('@')[1].toLowerCase();
return allowed.map((d) => d.toLowerCase().replace(/^@/, '')).includes(domain);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* OIDC provider configuration, persisted as a single JSONB row in app_settings
* under OIDC_SETTINGS_KEY. The client secret is encrypted at rest with the
* platform's self-healing key (utils/crypto). This key is deliberately excluded
* from the generic GET/PUT /api/settings surface (see routes/settings.ts) so the
* secret is only ever written through the dedicated, admin-only OIDC endpoints.
*/
import { query, queryOne } from '../../config/database';
import { encrypt, decrypt } from '../../utils/crypto';
export const OIDC_SETTINGS_KEY = 'oidc_config';
export type AppRole = 'admin' | 'operator' | 'viewer';
export const APP_ROLES: AppRole[] = ['admin', 'operator', 'viewer'];
export interface OidcConfig {
enabled: boolean;
issuer_url: string;
client_id: string;
/** AES-GCM ciphertext of the client secret, or null if none set. */
client_secret_encrypted: string | null;
scopes: string;
username_claim: string;
email_claim: string;
groups_claim: string;
group_role_map: Record<string, AppRole>;
default_role: AppRole;
auto_provision: boolean;
link_by_verified_email: boolean;
allowed_email_domains: string[];
button_label: string;
/** Optional override for building the redirect URI (else derived from the request). */
public_base_url: string;
}
export const DEFAULT_OIDC_CONFIG: OidcConfig = {
enabled: false,
issuer_url: '',
client_id: '',
client_secret_encrypted: null,
scopes: 'openid profile email',
username_claim: 'preferred_username',
email_claim: 'email',
groups_claim: 'groups',
group_role_map: {},
default_role: 'viewer',
auto_provision: true,
link_by_verified_email: true,
allowed_email_domains: [],
button_label: 'Sign in with SSO',
public_base_url: '',
};
export async function loadOidcConfig(): Promise<OidcConfig> {
const row = await queryOne<{ value: Partial<OidcConfig> }>(
`SELECT value FROM app_settings WHERE key = $1`,
[OIDC_SETTINGS_KEY]
);
return { ...DEFAULT_OIDC_CONFIG, ...(row?.value ?? {}) };
}
/**
* Persist config. `newSecret` (plaintext) is encrypted and stored; pass undefined
* to keep the existing secret, or '' to clear it.
*/
export async function saveOidcConfig(
patch: Partial<OidcConfig>,
newSecret?: string
): Promise<OidcConfig> {
const current = await loadOidcConfig();
const merged: OidcConfig = { ...current, ...patch };
if (newSecret !== undefined) {
merged.client_secret_encrypted = newSecret ? encrypt(newSecret) : null;
}
await query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
[OIDC_SETTINGS_KEY, JSON.stringify(merged)]
);
return merged;
}
export function getClientSecret(config: OidcConfig): string | null {
if (!config.client_secret_encrypted) return null;
try {
return decrypt(config.client_secret_encrypted);
} catch {
return null;
}
}
/** Config shape safe to return to an admin UI — no secret material. */
export function maskedConfig(config: OidcConfig): Omit<OidcConfig, 'client_secret_encrypted'> & { has_secret: boolean } {
const { client_secret_encrypted, ...rest } = config;
return { ...rest, has_secret: !!client_secret_encrypted };
}