feat(auth): OIDC SSO for admin users — phase 1 (#798)

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.
This commit is contained in:
Paul Nothaft
2026-07-16 08:54:26 +02:00
parent f0cdcddb92
commit ed5fc5ad5c
17 changed files with 1507 additions and 18 deletions
+108 -1
View File
@@ -36,7 +36,10 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
// (#800; writing false would reopen system-event-type deletion) and
// setup_token is the first-run bootstrap secret. Every handler that loops
// arbitrary request keys into app_settings must strip these first.
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
// oidc_client_secret is reserved too: it is AES-encrypted at rest and only
// writable through PUT /sso below — a generic upsert would store plaintext
// and break decryption (#798).
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token', 'oidc_client_secret'];
const stripReservedSettingKeys = (settings) => {
for (const key of RESERVED_SETTING_KEYS) {
delete settings[key];
@@ -376,6 +379,110 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
});
// Get settings by type
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
// because the client secret must be encrypted at rest and never echoed back.
// ──────────────────────────────────────────────────────────────────────────
// Read the SSO config. The secret is redacted to a set/unset flag; the
// computed redirect URI is included for copy-paste into the IdP client.
router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
res.json({
oidc_enabled: cfg.enabled,
oidc_issuer_url: cfg.issuerUrl || '',
oidc_client_id: cfg.clientId || '',
oidc_client_secret_set: Boolean(cfg.clientSecret),
oidc_autoprovision: cfg.autoprovision,
oidc_default_role: cfg.defaultRole,
oidc_button_label: cfg.buttonLabel || '',
oidc_scopes: cfg.scopes,
redirect_uri: await oidcService.getRedirectUri(),
});
} catch (error) {
logger.error('Failed to read SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to read SSO settings' });
}
});
router.put('/sso', adminAuth, requirePermission('settings.edit'), [
body('oidc_enabled').optional().isBoolean(),
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
body('oidc_client_id').optional().isString().trim(),
body('oidc_client_secret').optional().isString(),
body('oidc_autoprovision').optional().isBoolean(),
body('oidc_default_role').optional().isString().trim(),
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
body('oidc_scopes').optional().isString().trim(),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const oidcService = require('../services/oidcService');
// Enabling requires a complete config, or the login button would lead
// straight to an error page.
if (req.body.oidc_enabled === true) {
const current = await oidcService.getOidcConfig();
const issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
const clientId = req.body.oidc_client_id ?? current.clientId;
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|| Boolean(current.clientSecret);
if (!issuer || !clientId || !secretPresent) {
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured before enabling SSO' });
}
}
// Default role must exist — a typo here would brick JIT provisioning.
if (req.body.oidc_default_role !== undefined) {
const role = await db('roles').where('name', req.body.oidc_default_role).first();
if (!role) {
return res.status(400).json({ error: `Unknown role: ${req.body.oidc_default_role}` });
}
}
await oidcService.saveOidcSettings(req.body);
await logActivity('sso_settings_updated',
{ changes: Object.keys(req.body).filter((k) => k !== 'oidc_client_secret') },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'SSO settings saved' });
} catch (error) {
logger.error('Failed to save SSO settings', { error: error.message });
res.status(500).json({ error: 'Failed to save SSO settings' });
}
});
// Server-side discovery probe: confirms the issuer is reachable and speaks
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const oidcService = require('../services/oidcService');
const cfg = await oidcService.getOidcConfig();
if (!oidcService.isConfigured(cfg)) {
return res.status(400).json({ ok: false, error: 'Issuer URL, client ID and client secret must be saved first' });
}
oidcService.invalidateDiscoveryCache();
const { issuerMetadata } = await oidcService.getClient(cfg);
res.json({
ok: true,
issuer: issuerMetadata.issuer,
authorization_endpoint: issuerMetadata.authorization_endpoint,
token_endpoint: issuerMetadata.token_endpoint,
});
} catch (error) {
logger.warn('SSO discovery test failed', { error: error.message });
res.status(400).json({ ok: false, error: `Discovery failed: ${error.message}` });
}
});
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { type } = req.params;
+127 -13
View File
@@ -42,7 +42,7 @@ const router = express.Router();
* both produce an identical session. `lockoutKey` is the identifier the user
* typed (username or email) so success/failure tracking stays in one bucket.
*/
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey) {
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
// A normal login means the first-run wizard is over — the wizard never hits
@@ -75,18 +75,21 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
setAdminAuthCookie(res, token);
return res.json({
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
return {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
};
}
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
const user = await establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey);
return res.json({ user });
}
// Admin login with enhanced security
@@ -848,4 +851,115 @@ router.post('/password-strength', [
}
});
// ──────────────────────────────────────────────────────────────────────────
// OIDC SSO for admins (#798, phase 1)
//
// Authorization-code + PKCE. The per-request secrets (state, nonce, PKCE
// verifier) cross the IdP redirect in a short-lived signed cookie —
// httpOnly, SameSite=Lax (the IdP returns via a top-level GET, which Lax
// permits), scoped to this route prefix. Token/claim validation happens in
// oidcService via openid-client; a successful callback reuses the exact
// session establishment of the local login, so an SSO session is
// indistinguishable from a password one downstream. MFA is the IdP's job on
// this path — local TOTP guards the password flow SSO users don't take.
// ──────────────────────────────────────────────────────────────────────────
const OIDC_STATE_COOKIE = 'oidc_state';
function oidcStateCookieOptions(req) {
return {
httpOnly: true,
secure: Boolean(req.secure),
sameSite: 'Lax',
path: '/api/auth/admin/sso',
maxAge: 10 * 60 * 1000,
};
}
// Kick off the IdP round-trip. 404 when SSO is off so the endpoint is
// invisible on non-SSO installs.
router.get('/admin/sso/login', async (req, res) => {
const oidcService = require('../services/oidcService');
try {
const { url, state, nonce, codeVerifier } = await oidcService.buildAuthorizationRequest();
const stash = jwt.sign(
{ type: 'oidc_state', s: state, n: nonce, cv: codeVerifier },
process.env.JWT_SECRET,
{ expiresIn: '10m', issuer: 'picpeak-auth' }
);
res.cookie(OIDC_STATE_COOKIE, stash, oidcStateCookieOptions(req));
return res.redirect(url);
} catch (error) {
if (error.code === 'OIDC_NOT_CONFIGURED') {
return res.status(404).json({ error: 'SSO is not enabled' });
}
logger.error('OIDC login initiation failed', { error: error.message });
return res.redirect('/admin/login?sso_error=config');
}
});
// IdP redirect target. Every failure lands back on the login page with a
// translatable error key — never a raw error, never a broken JSON screen.
router.get('/admin/sso/callback', async (req, res) => {
const oidcService = require('../services/oidcService');
const fail = (key) => res.redirect(`/admin/login?sso_error=${key}`);
const stashCookie = req.cookies?.[OIDC_STATE_COOKIE];
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
if (!stashCookie) return fail('state');
let stash;
try {
stash = jwt.verify(stashCookie, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
if (stash.type !== 'oidc_state') throw new Error('wrong token type');
} catch (_) {
return fail('state');
}
try {
// Reconstruct the exact redirect URI + the IdP's query for validation.
const callbackUrl = new URL(await oidcService.getRedirectUri());
callbackUrl.search = req.originalUrl.split('?')[1] || '';
const claims = await oidcService.handleCallback(callbackUrl.href, {
state: stash.s,
nonce: stash.n,
codeVerifier: stash.cv,
});
const resolved = await oidcService.resolveAdminFromClaims(claims);
// Reload with role info so the session payload matches a local login.
const admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', resolved.id)
.select('admin_users.*', 'roles.name as role_name', 'roles.display_name as role_display_name')
.first();
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
await establishAdminSession(res, admin, ipAddress, userAgent, admin.username);
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
type: 'admin', id: admin.id, name: admin.username,
});
return res.redirect('/admin/dashboard');
} catch (error) {
const codeMap = {
OIDC_NOT_CONFIGURED: 'config',
OIDC_BAD_CONFIG: 'config',
OIDC_INACTIVE: 'inactive',
OIDC_NOT_PROVISIONED: 'not_provisioned',
OIDC_NO_EMAIL: 'no_email',
OIDC_BAD_CLAIMS: 'idp',
};
const key = codeMap[error.code] || 'idp';
// 'idp' covers token-exchange/validation failures from openid-client
// (bad state/nonce, signature, issuer mismatch, IdP-side errors).
logger.warn('OIDC callback failed', { error: error.message, key });
return fail(key);
}
});
module.exports = router;
+10 -1
View File
@@ -27,7 +27,12 @@ router.get('/', async (req, res) => {
// backend route also enforces it via getMaxFilesPerUpload,
// but a client-side guard saves a 4MB+ round-trip when the
// limit is small.
'general_max_files_per_upload'
'general_max_files_per_upload',
// #798 — the admin login page needs to know whether to show
// the "Sign in with SSO" button (and its label). Only these
// two oidc_* keys are public; issuer/client stay admin-only.
'oidc_enabled',
'oidc_button_label'
]);
})
.select('setting_key', 'setting_value');
@@ -128,6 +133,10 @@ router.get('/', async (req, res) => {
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
// OIDC SSO (#798): the admin login page renders the "Sign in with
// SSO" button from these. Issuer/client/secret are never public.
oidc_enabled: settingsObject.oidc_enabled === true,
oidc_button_label: settingsObject.oidc_button_label || '',
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
+359
View File
@@ -0,0 +1,359 @@
/**
* 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,
};