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
+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);
});