* feat(auth): OIDC role mapping + login policy — phase 2 (#798) Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles, Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping table validated against the roles table, re-evaluated on every SSO login with highest-priority-wins on multiple matches. The last active super_admin is never demoted. Optional require-mapped-role policy refuses logins whose token maps to no role (sso_error=no_role). Login policy: oidc_disable_local_login makes the API refuse password logins (403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens local login. Public settings expose the EFFECTIVE flag only. Settings UI: Role-mapping card (claim path, mapping rows editor, strict toggle) and Login-policy card with break-glass hint, EN+DE. 14 new integration tests over the mock IdP. * fix(auth): harden phase-2 review findings (#798) - memoize the scrypt-derived OIDC key and serve /public/settings from a 10s-TTL flag cache — the unauthenticated endpoint no longer pays a 13-key config read + blocking scryptSync per request (login route still checks uncached) - make the last-super-admin demotion guard atomic (FOR UPDATE on the active super rows) — concurrent mapped callbacks could previously both count 2 and demote both supers - own-property lookup in role mapping: IdP values like `constructor` now count as unmapped instead of corrupting the roles query - SsoTab clears oidc_disable_local_login in the same save that turns SSO off — the full-form payload otherwise hit the server-side 400 * fix(auth): guarantee break-glass reachability for SSO-only mode (#798) - wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start docker-compose.yml env allowlist (production compose already passes .env via env_file) and document both in .env.example - refuse enabling oidc_disable_local_login unless an active local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the password route, which OIDC-owned accounts can never use, and settings.edit is super_admin-only — an all-OIDC instance would be unrecoverable during an IdP outage * fix(auth): close SSO-only lockout gaps from review round 3 (#798) - role sync never demotes the last active LOCAL-password super_admin (an OIDC-owned super does not count as break-glass), and isLocalLoginDisabled() disarms itself when no such account remains — self-healing against manual demotion/deactivation/deletion paths - the local-super save-time check now validates the MERGED state, so re-enabling SSO with a stored disable flag is checked too - ALL oidc_* keys are reserved from the generic settings upserts/reads (prefix match) — policy and mapping invariants can only go through the validated PUT /sso - /admin/login/mfa re-checks the policy so an mfa_pending token minted before the flip cannot complete into a local session --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
ad326da35c
commit
f8a95d29d2
@@ -39,10 +39,15 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
// 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 RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
|
||||
// EVERY oidc_* key is reserved (#798 phase 2): the client secret would be
|
||||
// clobbered with plaintext, and the policy/mapping keys carry invariants
|
||||
// (role targets exist, break-glass account present) that only the dedicated
|
||||
// PUT /sso validates — a generic upsert would bypass all of them.
|
||||
const isReservedSettingKey = (key) => RESERVED_SETTING_KEYS.includes(key) || key.startsWith('oidc_');
|
||||
const stripReservedSettingKeys = (settings) => {
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settings[key];
|
||||
for (const key of Object.keys(settings)) {
|
||||
if (isReservedSettingKey(key)) delete settings[key];
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
@@ -163,9 +168,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settingsObject[key];
|
||||
}
|
||||
stripReservedSettingKeys(settingsObject);
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
@@ -436,6 +439,11 @@ router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, re
|
||||
oidc_default_role: cfg.defaultRole,
|
||||
oidc_button_label: cfg.buttonLabel || '',
|
||||
oidc_scopes: cfg.scopes,
|
||||
oidc_role_mapping_enabled: cfg.roleMappingEnabled,
|
||||
oidc_roles_claim: cfg.rolesClaim,
|
||||
oidc_role_mappings: cfg.roleMappings,
|
||||
oidc_require_mapped_role: cfg.requireMappedRole,
|
||||
oidc_disable_local_login: cfg.disableLocalLogin,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -453,6 +461,11 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [
|
||||
body('oidc_default_role').optional().isString().trim(),
|
||||
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
|
||||
body('oidc_scopes').optional().isString().trim(),
|
||||
body('oidc_role_mapping_enabled').optional().isBoolean(),
|
||||
body('oidc_roles_claim').optional().isString().trim().isLength({ max: 200 }),
|
||||
body('oidc_role_mappings').optional().isObject(),
|
||||
body('oidc_require_mapped_role').optional().isBoolean(),
|
||||
body('oidc_disable_local_login').optional().isBoolean(),
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -492,6 +505,42 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [
|
||||
}
|
||||
}
|
||||
|
||||
// Every role-mapping target must exist too (#798 phase 2) — a typo'd
|
||||
// role name would silently map users to nothing.
|
||||
if (req.body.oidc_role_mappings !== undefined) {
|
||||
const targets = [...new Set(Object.values(req.body.oidc_role_mappings).map((r) => String(r).trim()).filter(Boolean))];
|
||||
if (targets.length > 0) {
|
||||
const known = (await db('roles').whereIn('name', targets)).map((r) => r.name);
|
||||
const unknown = targets.filter((t) => !known.includes(t));
|
||||
if (unknown.length > 0) {
|
||||
return res.status(400).json({ error: `Unknown role(s) in mapping: ${unknown.join(', ')}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Turning OFF local login requires SSO to be (staying) enabled. Only the
|
||||
// explicit request is checked — a stored true must never block disabling
|
||||
// SSO itself (runtime enforcement ignores the flag while SSO is off or
|
||||
// unconfigured, and OIDC_BREAK_GLASS=true always re-opens local login).
|
||||
if (req.body.oidc_disable_local_login === true && effectiveEnabled !== true) {
|
||||
return res.status(400).json({ error: 'Local login can only be disabled while SSO is enabled' });
|
||||
}
|
||||
|
||||
// …and an active LOCAL-password super_admin must exist as the break-glass
|
||||
// account: OIDC_BREAK_GLASS only re-opens the password route, but
|
||||
// OIDC-owned accounts are refused there and carry unusable random hashes.
|
||||
// settings.edit is super_admin-only, so a lesser local account couldn't
|
||||
// fix the SSO config either — without this check an all-OIDC instance
|
||||
// would be unrecoverable during an IdP outage. Checked on the MERGED
|
||||
// state, not just the request: re-enabling SSO while a stored true flag
|
||||
// re-arms SSO-only mode just as much as setting the flag itself.
|
||||
const effectiveDisableLocal = req.body.oidc_disable_local_login ?? current.disableLocalLogin;
|
||||
if (effectiveDisableLocal === true && effectiveEnabled === true) {
|
||||
if (!(await oidcService.hasActiveLocalSuperAdmin())) {
|
||||
return res.status(400).json({ error: 'Disabling local login requires at least one active Super Admin with a local password (the break-glass account)' });
|
||||
}
|
||||
}
|
||||
|
||||
await oidcService.saveOidcSettings(req.body);
|
||||
|
||||
await logActivity('sso_settings_updated',
|
||||
@@ -564,9 +613,7 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settingsObject[key];
|
||||
}
|
||||
stripReservedSettingKeys(settingsObject);
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
|
||||
@@ -106,7 +106,15 @@ router.post('/admin/login', [
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
|
||||
// SSO login policy (#798 phase 2): refuse the password path before any
|
||||
// credential/lockout work while oidc_disable_local_login is effective.
|
||||
// OIDC_BREAK_GLASS=true (checked inside) always re-opens local login.
|
||||
if (await require('../services/oidcService').isLocalLoginDisabled()) {
|
||||
logger.warn('Local admin login refused — disabled by SSO policy', { username, ipAddress });
|
||||
return res.status(403).json({ error: 'Local login is disabled — sign in through SSO', code: 'LOCAL_LOGIN_DISABLED' });
|
||||
}
|
||||
|
||||
// Check account lockout first
|
||||
const lockoutStatus = await checkAccountLockout(username);
|
||||
if (lockoutStatus.isLocked) {
|
||||
@@ -198,6 +206,14 @@ router.post('/admin/login/mfa', [
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Same SSO policy gate as /admin/login (#798 phase 2): an mfa_pending
|
||||
// token minted moments before the policy flipped must not complete into
|
||||
// a local session through this second step.
|
||||
if (await require('../services/oidcService').isLocalLoginDisabled()) {
|
||||
logger.warn('Local admin MFA completion refused — disabled by SSO policy', { ipAddress });
|
||||
return res.status(403).json({ error: 'Local login is disabled — sign in through SSO', code: 'LOCAL_LOGIN_DISABLED' });
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||
@@ -1001,6 +1017,7 @@ router.get('/admin/sso/callback', async (req, res) => {
|
||||
OIDC_INACTIVE: 'inactive',
|
||||
OIDC_NOT_PROVISIONED: 'not_provisioned',
|
||||
OIDC_NO_EMAIL: 'no_email',
|
||||
OIDC_NO_ROLE: 'no_role',
|
||||
OIDC_BAD_CLAIMS: 'idp',
|
||||
};
|
||||
const key = codeMap[error.code] || 'idp';
|
||||
|
||||
@@ -142,6 +142,13 @@ router.get('/', async (req, res) => {
|
||||
// SSO" button from these. Issuer/client/secret are never public.
|
||||
oidc_enabled: settingsObject.oidc_enabled === true,
|
||||
oidc_button_label: settingsObject.oidc_button_label || '',
|
||||
// EFFECTIVE flag (phase 2): true only while the backend would actually
|
||||
// refuse a password login (SSO enabled + configured + policy on, no
|
||||
// break-glass env) — the login page hides the password form from this,
|
||||
// so it must never claim "disabled" when the API would still allow it.
|
||||
// Cached (10s TTL): this endpoint is unauthenticated and hit by every
|
||||
// new client; the login route itself always checks uncached.
|
||||
oidc_local_login_disabled: await require('../services/oidcService').isLocalLoginDisabledCached().catch(() => false),
|
||||
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',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* OIDC SSO for admin users (#798, phase 1).
|
||||
* OIDC SSO for admin users (#798).
|
||||
*
|
||||
* 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.
|
||||
* Authentik, Pocket ID, or any spec-compliant provider). Phase 1: admin
|
||||
* logins only, JIT provisioning with one default role. Phase 2: role-claim
|
||||
* mapping (dot-path claim, re-evaluated per login) and login policy
|
||||
* (require-mapped-role, disable-local-login with OIDC_BREAK_GLASS env
|
||||
* escape hatch). Logout-to-IdP is a follow-up.
|
||||
*
|
||||
* Identity binding: SSO logins match on `admin_users.external_subject` (the
|
||||
* IdP's stable `sub` claim) — NEVER on email alone, which is an
|
||||
@@ -40,12 +42,22 @@ const logger = require('../utils/logger');
|
||||
const ENC_ALGO = 'aes-256-gcm';
|
||||
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
|
||||
|
||||
// scrypt is deliberately expensive and the derivation is deterministic per
|
||||
// process — memoize it, or every decrypt (e.g. config reads) burns ~50ms of
|
||||
// blocking CPU on the event loop.
|
||||
let _encKeyCache = null; // { material, key }
|
||||
|
||||
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);
|
||||
if (_encKeyCache && _encKeyCache.material === material) {
|
||||
return _encKeyCache.key;
|
||||
}
|
||||
const key = crypto.scryptSync(material, ENC_SALT, 32);
|
||||
_encKeyCache = { material, key };
|
||||
return key;
|
||||
}
|
||||
|
||||
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (all base64url). */
|
||||
@@ -76,7 +88,8 @@ function decryptSecret(stored) {
|
||||
* for internal use only; the settings GET endpoint must never call this.
|
||||
*/
|
||||
async function getOidcConfig() {
|
||||
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes] =
|
||||
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes,
|
||||
roleMappingEnabled, rolesClaim, roleMappings, requireMappedRole, disableLocalLogin] =
|
||||
await Promise.all([
|
||||
getAppSetting('oidc_enabled'),
|
||||
getAppSetting('oidc_issuer_url'),
|
||||
@@ -86,6 +99,11 @@ async function getOidcConfig() {
|
||||
getAppSetting('oidc_default_role'),
|
||||
getAppSetting('oidc_button_label'),
|
||||
getAppSetting('oidc_scopes'),
|
||||
getAppSetting('oidc_role_mapping_enabled'),
|
||||
getAppSetting('oidc_roles_claim'),
|
||||
getAppSetting('oidc_role_mappings'),
|
||||
getAppSetting('oidc_require_mapped_role'),
|
||||
getAppSetting('oidc_disable_local_login'),
|
||||
]);
|
||||
|
||||
let clientSecret = null;
|
||||
@@ -110,9 +128,73 @@ async function getOidcConfig() {
|
||||
defaultRole: defaultRole || 'viewer',
|
||||
buttonLabel: buttonLabel || null,
|
||||
scopes: scopes || 'openid profile email',
|
||||
roleMappingEnabled: roleMappingEnabled === true,
|
||||
rolesClaim: rolesClaim || 'roles',
|
||||
roleMappings: (roleMappings && typeof roleMappings === 'object' && !Array.isArray(roleMappings))
|
||||
? roleMappings : {},
|
||||
requireMappedRole: requireMappedRole === true,
|
||||
disableLocalLogin: disableLocalLogin === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** auth_provider values that can use the password route ('local' or legacy NULL). */
|
||||
function isLocalProvider(authProvider) {
|
||||
return authProvider !== 'oidc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-glass viability: an ACTIVE super_admin with a usable local password.
|
||||
* OIDC_BREAK_GLASS only re-opens the password route — OIDC-owned accounts
|
||||
* are refused there and carry unusable random hashes, and settings.edit is
|
||||
* super_admin-only, so this is the one account shape that can repair a
|
||||
* broken SSO config.
|
||||
*/
|
||||
async function hasActiveLocalSuperAdmin(conn = db) {
|
||||
const superAdminRole = await conn('roles').where('name', 'super_admin').first();
|
||||
if (!superAdminRole) return false;
|
||||
const row = await conn('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where((qb) => qb.whereNot('auth_provider', 'oidc').orWhereNull('auth_provider'))
|
||||
.first();
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the local password login must be refused (#798 phase 2 policy).
|
||||
* Only effective while SSO is actually usable (enabled + fully configured) —
|
||||
* a half-torn-down config must never lock the instance. OIDC_BREAK_GLASS=true
|
||||
* re-enables local login unconditionally so an IdP outage or a role-mapping
|
||||
* misconfig always has a documented way back in. The policy also disarms
|
||||
* itself when no active local-password super_admin remains (however that
|
||||
* happened — manual demotion, deactivation, deletion): break-glass would
|
||||
* otherwise re-open a password route no usable account can take.
|
||||
*/
|
||||
async function isLocalLoginDisabled() {
|
||||
if (process.env.OIDC_BREAK_GLASS === 'true') return false;
|
||||
const cfg = await getOidcConfig();
|
||||
if (!(cfg.enabled && cfg.disableLocalLogin && isConfigured(cfg))) return false;
|
||||
return hasActiveLocalSuperAdmin();
|
||||
}
|
||||
|
||||
// Short-TTL cache of the flag for the UNAUTHENTICATED public-settings
|
||||
// endpoint, which every new client hits — without it each request pays the
|
||||
// full 13-key config read. The login route keeps using the uncached check
|
||||
// (enforcement must be exact); a UI that lags a settings flip by ≤10s only
|
||||
// shows a form whose submit the API answers authoritatively. Invalidated on
|
||||
// every settings save in this worker.
|
||||
const FLAG_CACHE_TTL_MS = 10 * 1000;
|
||||
let _localLoginFlagCache = null; // { value, at }
|
||||
|
||||
async function isLocalLoginDisabledCached() {
|
||||
if (_localLoginFlagCache && Date.now() - _localLoginFlagCache.at < FLAG_CACHE_TTL_MS) {
|
||||
return _localLoginFlagCache.value;
|
||||
}
|
||||
const value = await isLocalLoginDisabled();
|
||||
_localLoginFlagCache = { value, at: Date.now() };
|
||||
return value;
|
||||
}
|
||||
|
||||
function isConfigured(cfg) {
|
||||
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
|
||||
}
|
||||
@@ -260,6 +342,109 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the IdP role values out of the claims via a dot-path (#798 phase 2).
|
||||
* Keycloak nests them (`realm_access.roles`), Authentik uses a flat `groups`,
|
||||
* Entra a flat `roles`/`groups` — a dot-path covers all of them. Accepts an
|
||||
* array, a single string, or a space/comma-separated string value.
|
||||
*/
|
||||
function extractRolesFromClaims(claims, claimPath) {
|
||||
const path = String(claimPath || '').trim();
|
||||
if (!path) return [];
|
||||
let value = claims;
|
||||
for (const segment of path.split('.')) {
|
||||
if (value == null || typeof value !== 'object') return [];
|
||||
value = value[segment];
|
||||
}
|
||||
if (Array.isArray(value)) return value.filter((v) => typeof v === 'string');
|
||||
if (typeof value === 'string') return value.split(/[\s,]+/).filter(Boolean);
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the IdP-asserted roles to ONE PicPeak role row through the
|
||||
* configured mapping table, or null when nothing maps. Several matches →
|
||||
* the highest-priority role wins (roles.priority, super_admin=100 …
|
||||
* viewer=20). Mapping targets that don't exist in the roles table anymore
|
||||
* (deleted custom role) simply resolve to nothing.
|
||||
*/
|
||||
async function resolveMappedRole(claims, cfg) {
|
||||
const idpRoles = extractRolesFromClaims(claims, cfg.rolesClaim);
|
||||
// Own-property lookup only: an IdP value like `constructor` or `toString`
|
||||
// would otherwise resolve to an inherited function and corrupt the query —
|
||||
// such values must count as unmapped, not break the login.
|
||||
const targetNames = [...new Set(
|
||||
idpRoles
|
||||
.map((r) => (Object.hasOwn(cfg.roleMappings, r) ? cfg.roleMappings[r] : null))
|
||||
.filter((v) => typeof v === 'string' && v.length > 0)
|
||||
)];
|
||||
if (targetNames.length === 0) return null;
|
||||
const roles = await db('roles').whereIn('name', targetNames).orderBy('priority', 'desc');
|
||||
return roles[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the mapped role to an admin row (role re-evaluated on every SSO
|
||||
* login — the IdP is the source of truth while mapping is enabled). One
|
||||
* guard: NEVER demote the last active super_admin, or a bad IdP group
|
||||
* change would leave the instance without user management (same invariant
|
||||
* userManagementService enforces on manual role edits). `mappedRole=null`
|
||||
* (mapping off, or nothing mapped in non-strict mode) keeps the current role.
|
||||
*/
|
||||
async function syncAdminRole(admin, mappedRole) {
|
||||
if (!mappedRole || admin.role_id === mappedRole.id) return admin;
|
||||
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
if (superAdminRole && admin.role_id === superAdminRole.id && mappedRole.id !== superAdminRole.id) {
|
||||
// Demotion of a super_admin must be count-and-update ATOMIC: two supers
|
||||
// finishing mapped callbacks concurrently would otherwise both count 2
|
||||
// and both demote, leaving zero super_admins. FOR UPDATE on the active
|
||||
// super rows serializes concurrent demotions on Postgres (the second
|
||||
// waiter re-reads after the first commits and sees the shrunken set);
|
||||
// SQLite ignores forUpdate but is single-writer anyway. Use only `trx`
|
||||
// inside — a global-db read here would deadlock SQLite's one-connection
|
||||
// pool (see utils/appSettings.js).
|
||||
let demoted = false;
|
||||
await db.transaction(async (trx) => {
|
||||
const activeSupers = await trx('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.select('id', 'auth_provider')
|
||||
.forUpdate();
|
||||
const others = activeSupers.filter((row) => row.id !== admin.id);
|
||||
if (others.length === 0) return; // last active super_admin — keep
|
||||
// A LOCAL-password super is also the break-glass account for SSO-only
|
||||
// mode: demoting the last one would leave only OIDC-owned supers,
|
||||
// which the password route refuses — keep it regardless of what the
|
||||
// IdP asserts.
|
||||
if (isLocalProvider(admin.auth_provider) && !others.some((row) => isLocalProvider(row.auth_provider))) {
|
||||
return;
|
||||
}
|
||||
await trx('admin_users').where('id', admin.id).update({
|
||||
role_id: mappedRole.id,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
demoted = true;
|
||||
});
|
||||
if (!demoted) {
|
||||
logger.warn('OIDC role sync would demote the last active (local-password) super_admin — keeping super_admin', {
|
||||
adminId: admin.id,
|
||||
mappedRole: mappedRole.name,
|
||||
});
|
||||
return admin;
|
||||
}
|
||||
logger.info('OIDC: admin role synced from IdP claims', { adminId: admin.id, role: mappedRole.name });
|
||||
return { ...admin, role_id: mappedRole.id };
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
role_id: mappedRole.id,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
logger.info('OIDC: admin role synced from IdP claims', { adminId: admin.id, role: mappedRole.name });
|
||||
return { ...admin, role_id: mappedRole.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated ID token claims to an admin_users row.
|
||||
*
|
||||
@@ -289,6 +474,22 @@ async function resolveAdminFromClaims(claims) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const cfg = await getOidcConfig();
|
||||
|
||||
// Role mapping (#798 phase 2): resolve the IdP-asserted role ONCE, before
|
||||
// any account resolution — in strict mode a login without a mapped role is
|
||||
// refused no matter how the identity would have resolved ("only members of
|
||||
// group X may enter"). The mapped role is then applied on every path below.
|
||||
let mappedRole = null;
|
||||
if (cfg.roleMappingEnabled) {
|
||||
mappedRole = await resolveMappedRole(claims, cfg);
|
||||
if (!mappedRole && cfg.requireMappedRole) {
|
||||
const err = new Error('ID token carries no role that maps to a PicPeak role');
|
||||
err.code = 'OIDC_NO_ROLE';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Established binding — issuer AND subject.
|
||||
const bySub = await db('admin_users')
|
||||
.where('external_issuer', iss)
|
||||
@@ -300,7 +501,7 @@ async function resolveAdminFromClaims(claims) {
|
||||
err.code = 'OIDC_INACTIVE';
|
||||
throw err;
|
||||
}
|
||||
return bySub;
|
||||
return syncAdminRole(bySub, mappedRole);
|
||||
}
|
||||
|
||||
// 2. One-time email link — verified emails only, and only onto rows that
|
||||
@@ -336,7 +537,7 @@ async function resolveAdminFromClaims(claims) {
|
||||
.where('external_issuer', iss)
|
||||
.where('external_subject', sub)
|
||||
.first();
|
||||
if (rebound && rebound.is_active) return rebound;
|
||||
if (rebound && rebound.is_active) return syncAdminRole(rebound, mappedRole);
|
||||
const err = new Error('Account link raced with another sign-in — try again');
|
||||
err.code = 'OIDC_BAD_CLAIMS';
|
||||
throw err;
|
||||
@@ -345,12 +546,11 @@ async function resolveAdminFromClaims(claims) {
|
||||
adminId: byEmail.id,
|
||||
sub,
|
||||
});
|
||||
return { ...byEmail, external_issuer: iss, external_subject: sub };
|
||||
return syncAdminRole({ ...byEmail, external_issuer: iss, external_subject: sub }, mappedRole);
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
@@ -362,7 +562,9 @@ async function resolveAdminFromClaims(claims) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const role = await db('roles').where('name', cfg.defaultRole).first();
|
||||
// Mapped role wins over the static default — the default only catches
|
||||
// users with no mapped IdP role while non-strict mapping is on.
|
||||
const role = mappedRole || 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';
|
||||
@@ -389,7 +591,7 @@ async function resolveAdminFromClaims(claims) {
|
||||
})
|
||||
.returning('id');
|
||||
const adminId = inserted[0]?.id || inserted[0];
|
||||
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: cfg.defaultRole });
|
||||
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: role.name });
|
||||
|
||||
return db('admin_users').where('id', adminId).first();
|
||||
}
|
||||
@@ -417,17 +619,40 @@ async function saveOidcSettings(input) {
|
||||
if (!scopes.includes('openid')) scopes.unshift('openid');
|
||||
put('oidc_scopes', scopes.join(' ') || 'openid profile email', 'string');
|
||||
}
|
||||
if (input.oidc_role_mapping_enabled !== undefined) put('oidc_role_mapping_enabled', input.oidc_role_mapping_enabled === true, 'boolean');
|
||||
if (input.oidc_roles_claim !== undefined) put('oidc_roles_claim', String(input.oidc_roles_claim).trim(), 'string');
|
||||
if (input.oidc_role_mappings !== undefined) {
|
||||
// Normalize to a flat { idpRole: picpeakRole } string map; the route has
|
||||
// already validated that every target role exists.
|
||||
const mappings = {};
|
||||
if (input.oidc_role_mappings && typeof input.oidc_role_mappings === 'object' && !Array.isArray(input.oidc_role_mappings)) {
|
||||
for (const [idpRole, picpeakRole] of Object.entries(input.oidc_role_mappings)) {
|
||||
const from = String(idpRole).trim();
|
||||
const to = String(picpeakRole).trim();
|
||||
if (from && to) mappings[from] = to;
|
||||
}
|
||||
}
|
||||
put('oidc_role_mappings', mappings, 'json');
|
||||
}
|
||||
if (input.oidc_require_mapped_role !== undefined) put('oidc_require_mapped_role', input.oidc_require_mapped_role === true, 'boolean');
|
||||
if (input.oidc_disable_local_login !== undefined) put('oidc_disable_local_login', input.oidc_disable_local_login === true, 'boolean');
|
||||
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();
|
||||
_localLoginFlagCache = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOidcConfig,
|
||||
isConfigured,
|
||||
isLocalLoginDisabled,
|
||||
isLocalLoginDisabledCached,
|
||||
hasActiveLocalSuperAdmin,
|
||||
extractRolesFromClaims,
|
||||
resolveMappedRole,
|
||||
getRedirectUri,
|
||||
buildAuthorizationRequest,
|
||||
handleCallback,
|
||||
|
||||
Reference in New Issue
Block a user