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