ed5fc5ad5c
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
/**
|
|
* OIDC SSO for admin users (#798, phase 1).
|
|
*
|
|
* Authorization-code + PKCE against a single configurable IdP (Keycloak,
|
|
* Authentik, Pocket ID, or any spec-compliant provider). Scope is deliberately
|
|
* narrow in phase 1: admin logins only, JIT provisioning with one default
|
|
* role. Role-claim mapping and logout-to-IdP are follow-ups.
|
|
*
|
|
* Identity binding: SSO logins match on `admin_users.external_subject` (the
|
|
* IdP's stable `sub` claim) — NEVER on email alone, which is an
|
|
* account-takeover vector with IdPs that don't verify addresses. A one-time
|
|
* link of an EXISTING local admin by email is allowed only when the ID token
|
|
* carries `email_verified: true`; the sub is stamped so all future logins
|
|
* match by sub even if the email changes. Linked local admins keep
|
|
* `auth_provider='local'` (their password still works); JIT-provisioned rows
|
|
* get `auth_provider='oidc'` and an unusable random password hash.
|
|
*
|
|
* Config lives in app_settings (oidc_* keys, managed via the dedicated
|
|
* /admin/settings/sso endpoints). The client secret is AES-256-GCM encrypted
|
|
* at rest — same construction as mfaService, own salt, key from
|
|
* OIDC_ENCRYPTION_KEY (fallback JWT_SECRET).
|
|
*
|
|
* MFA is delegated to the IdP for SSO logins: local TOTP protects the local
|
|
* password path, which SSO users don't take.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const bcrypt = require('bcrypt');
|
|
// openid-client v5 (CommonJS). v6+ is ESM-only, which Node 22 can require()
|
|
// but Jest's CJS runtime cannot — v5 is the battle-tested major and its
|
|
// protocol coverage (discovery, PKCE, full ID-token validation) is identical
|
|
// for our flow.
|
|
const { Issuer, generators } = require('openid-client');
|
|
const { db } = require('../database/db');
|
|
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
|
const { formatBoolean } = require('../utils/dbCompat');
|
|
const { getBcryptRounds } = require('../utils/passwordValidation');
|
|
const logger = require('../utils/logger');
|
|
|
|
const ENC_ALGO = 'aes-256-gcm';
|
|
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
|
|
|
|
function getEncryptionKey() {
|
|
const material = process.env.OIDC_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
|
if (!material) {
|
|
throw new Error('oidcService: OIDC_ENCRYPTION_KEY or JWT_SECRET must be set');
|
|
}
|
|
return crypto.scryptSync(material, ENC_SALT, 32);
|
|
}
|
|
|
|
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (all base64url). */
|
|
function encryptSecret(plainSecret) {
|
|
const key = getEncryptionKey();
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
|
|
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
|
|
}
|
|
|
|
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
|
|
function decryptSecret(stored) {
|
|
const key = getEncryptionKey();
|
|
const [ivB64, tagB64, ctB64] = String(stored).split('.');
|
|
if (!ivB64 || !tagB64 || !ctB64) {
|
|
throw new Error('oidcService: malformed encrypted secret');
|
|
}
|
|
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
|
|
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
|
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
|
|
return pt.toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Read the full OIDC config from app_settings. Secret is returned DECRYPTED —
|
|
* for internal use only; the settings GET endpoint must never call this.
|
|
*/
|
|
async function getOidcConfig() {
|
|
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes] =
|
|
await Promise.all([
|
|
getAppSetting('oidc_enabled'),
|
|
getAppSetting('oidc_issuer_url'),
|
|
getAppSetting('oidc_client_id'),
|
|
getAppSetting('oidc_client_secret'),
|
|
getAppSetting('oidc_autoprovision'),
|
|
getAppSetting('oidc_default_role'),
|
|
getAppSetting('oidc_button_label'),
|
|
getAppSetting('oidc_scopes'),
|
|
]);
|
|
|
|
let clientSecret = null;
|
|
if (encSecret) {
|
|
try {
|
|
clientSecret = decryptSecret(encSecret);
|
|
} catch (err) {
|
|
// Wrong key / tampered / plaintext-clobbered value → treat as
|
|
// unconfigured rather than sending garbage to the IdP.
|
|
logger.error('OIDC client secret could not be decrypted — treating SSO as unconfigured', {
|
|
error: err.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
enabled: enabled === true,
|
|
issuerUrl: issuerUrl || null,
|
|
clientId: clientId || null,
|
|
clientSecret,
|
|
autoprovision: autoprovision === true,
|
|
defaultRole: defaultRole || 'viewer',
|
|
buttonLabel: buttonLabel || null,
|
|
scopes: scopes || 'openid profile email',
|
|
};
|
|
}
|
|
|
|
function isConfigured(cfg) {
|
|
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
|
|
}
|
|
|
|
// Discovery result cache. Keyed by issuer+client so a settings change gets a
|
|
// fresh client; invalidated explicitly on settings save too.
|
|
let _clientCache = null; // { key, client, issuerMetadata }
|
|
|
|
function invalidateDiscoveryCache() {
|
|
_clientCache = null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the openid-client Client for the current settings, performing
|
|
* OIDC discovery on first use. Throws on unreachable/invalid issuer —
|
|
* callers surface that as a config error.
|
|
*/
|
|
async function getClient(cfg) {
|
|
const key = `${cfg.issuerUrl}|${cfg.clientId}`;
|
|
if (_clientCache && _clientCache.key === key) {
|
|
return _clientCache;
|
|
}
|
|
const issuer = await Issuer.discover(cfg.issuerUrl);
|
|
const client = new issuer.Client({
|
|
client_id: cfg.clientId,
|
|
client_secret: cfg.clientSecret,
|
|
redirect_uris: [await getRedirectUri()],
|
|
response_types: ['code'],
|
|
});
|
|
_clientCache = { key, client, issuerMetadata: issuer.metadata };
|
|
return _clientCache;
|
|
}
|
|
|
|
/**
|
|
* The redirect URI registered with the IdP. Derived from the public frontend
|
|
* base URL — nginx proxies /api to the backend, so this resolves publicly.
|
|
*/
|
|
async function getRedirectUri() {
|
|
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
|
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
|
|
return `${base}/api/auth/admin/sso/callback`;
|
|
}
|
|
|
|
/**
|
|
* Build the IdP authorization URL plus the per-request secrets the callback
|
|
* needs (state, nonce, PKCE verifier). The route stores those in a
|
|
* short-lived signed cookie — this service is stateless across the redirect.
|
|
*/
|
|
async function buildAuthorizationRequest() {
|
|
const cfg = await getOidcConfig();
|
|
if (!cfg.enabled || !isConfigured(cfg)) {
|
|
const err = new Error('SSO is not enabled or not fully configured');
|
|
err.code = 'OIDC_NOT_CONFIGURED';
|
|
throw err;
|
|
}
|
|
const { client } = await getClient(cfg);
|
|
|
|
const codeVerifier = generators.codeVerifier();
|
|
const codeChallenge = generators.codeChallenge(codeVerifier);
|
|
const state = generators.state();
|
|
const nonce = generators.nonce();
|
|
|
|
const url = client.authorizationUrl({
|
|
redirect_uri: await getRedirectUri(),
|
|
scope: cfg.scopes,
|
|
state,
|
|
nonce,
|
|
code_challenge: codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
return { url, state, nonce, codeVerifier };
|
|
}
|
|
|
|
/**
|
|
* Exchange the authorization code and validate the ID token (issuer,
|
|
* audience, signature, nonce, state — all enforced by openid-client).
|
|
* Returns the ID token claims.
|
|
*/
|
|
async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
|
const cfg = await getOidcConfig();
|
|
if (!cfg.enabled || !isConfigured(cfg)) {
|
|
const err = new Error('SSO is not enabled or not fully configured');
|
|
err.code = 'OIDC_NOT_CONFIGURED';
|
|
throw err;
|
|
}
|
|
const { client } = await getClient(cfg);
|
|
|
|
// Extract code/state from the callback URL, then exchange + validate the
|
|
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
|
|
// by openid-client).
|
|
const callbackUrl = new URL(currentUrl);
|
|
const params = Object.fromEntries(callbackUrl.searchParams.entries());
|
|
const tokenSet = await client.callback(await getRedirectUri(), params, {
|
|
state,
|
|
nonce,
|
|
code_verifier: codeVerifier,
|
|
});
|
|
|
|
return tokenSet.claims();
|
|
}
|
|
|
|
/**
|
|
* Map validated ID token claims to an admin_users row.
|
|
*
|
|
* Resolution order:
|
|
* 1. external_subject === sub → that admin (must be active).
|
|
* 2. email match against an UNLINKED admin, only if email_verified === true
|
|
* → one-time link (stamps external_subject; auth_provider unchanged so
|
|
* a local password keeps working).
|
|
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
|
|
* claim; role = oidc_default_role; unusable random password).
|
|
*
|
|
* Errors carry a `code` the route maps to a redirect error key.
|
|
*/
|
|
async function resolveAdminFromClaims(claims) {
|
|
const sub = claims.sub;
|
|
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
|
|
const emailVerified = claims.email_verified === true;
|
|
|
|
if (!sub) {
|
|
const err = new Error('ID token has no sub claim');
|
|
err.code = 'OIDC_BAD_CLAIMS';
|
|
throw err;
|
|
}
|
|
|
|
// 1. Established binding.
|
|
const bySub = await db('admin_users').where('external_subject', sub).first();
|
|
if (bySub) {
|
|
if (!bySub.is_active) {
|
|
const err = new Error('Admin account is deactivated');
|
|
err.code = 'OIDC_INACTIVE';
|
|
throw err;
|
|
}
|
|
return bySub;
|
|
}
|
|
|
|
// 2. One-time email link — verified emails only, and only onto rows that
|
|
// have no binding yet (a different sub on the row means a different
|
|
// IdP identity already owns it).
|
|
if (email && emailVerified) {
|
|
const byEmail = await db('admin_users')
|
|
.where('email', email)
|
|
.whereNull('external_subject')
|
|
.first();
|
|
if (byEmail) {
|
|
if (!byEmail.is_active) {
|
|
const err = new Error('Admin account is deactivated');
|
|
err.code = 'OIDC_INACTIVE';
|
|
throw err;
|
|
}
|
|
await db('admin_users').where('id', byEmail.id).update({
|
|
external_subject: sub,
|
|
updated_at: new Date(),
|
|
});
|
|
logger.info('OIDC: linked existing admin to IdP subject', {
|
|
adminId: byEmail.id,
|
|
sub,
|
|
});
|
|
return { ...byEmail, external_subject: sub };
|
|
}
|
|
}
|
|
|
|
// 3. JIT provisioning.
|
|
const cfg = await getOidcConfig();
|
|
if (!cfg.autoprovision) {
|
|
const err = new Error('No matching admin account and auto-provisioning is disabled');
|
|
err.code = 'OIDC_NOT_PROVISIONED';
|
|
throw err;
|
|
}
|
|
if (!email) {
|
|
const err = new Error('IdP supplied no email claim — cannot provision an account');
|
|
err.code = 'OIDC_NO_EMAIL';
|
|
throw err;
|
|
}
|
|
|
|
const role = await db('roles').where('name', cfg.defaultRole).first();
|
|
if (!role) {
|
|
const err = new Error(`Configured default role '${cfg.defaultRole}' does not exist`);
|
|
err.code = 'OIDC_BAD_CONFIG';
|
|
throw err;
|
|
}
|
|
|
|
// Unusable-but-valid bcrypt hash: local login always fails for this row,
|
|
// and nothing downstream chokes on a malformed hash.
|
|
const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), getBcryptRounds());
|
|
|
|
const inserted = await db('admin_users')
|
|
.insert({
|
|
username: email,
|
|
email,
|
|
password_hash: passwordHash,
|
|
role_id: role.id,
|
|
is_active: formatBoolean(true),
|
|
must_change_password: formatBoolean(false),
|
|
auth_provider: 'oidc',
|
|
external_subject: sub,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
})
|
|
.returning('id');
|
|
const adminId = inserted[0]?.id || inserted[0];
|
|
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: cfg.defaultRole });
|
|
|
|
return db('admin_users').where('id', adminId).first();
|
|
}
|
|
|
|
/**
|
|
* Persist SSO settings (dedicated endpoint — the generic settings upserts
|
|
* strip oidc_client_secret so it can't be clobbered with plaintext).
|
|
* An absent/empty secret keeps the stored one.
|
|
*/
|
|
async function saveOidcSettings(input) {
|
|
const writes = [];
|
|
const put = (key, value, type) => writes.push(upsertAppSetting(key, JSON.stringify(value), type));
|
|
|
|
if (input.oidc_enabled !== undefined) put('oidc_enabled', input.oidc_enabled === true, 'boolean');
|
|
if (input.oidc_issuer_url !== undefined) put('oidc_issuer_url', String(input.oidc_issuer_url).trim(), 'string');
|
|
if (input.oidc_client_id !== undefined) put('oidc_client_id', String(input.oidc_client_id).trim(), 'string');
|
|
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
|
|
if (input.oidc_default_role !== undefined) put('oidc_default_role', String(input.oidc_default_role).trim(), 'string');
|
|
if (input.oidc_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
|
|
if (input.oidc_scopes !== undefined) put('oidc_scopes', String(input.oidc_scopes).trim() || 'openid profile email', 'string');
|
|
if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) {
|
|
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
|
|
}
|
|
|
|
await Promise.all(writes);
|
|
invalidateDiscoveryCache();
|
|
}
|
|
|
|
module.exports = {
|
|
getOidcConfig,
|
|
isConfigured,
|
|
getRedirectUri,
|
|
buildAuthorizationRequest,
|
|
handleCallback,
|
|
resolveAdminFromClaims,
|
|
saveOidcSettings,
|
|
invalidateDiscoveryCache,
|
|
getClient,
|
|
encryptSecret,
|
|
decryptSecret,
|
|
};
|