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
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user