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