feat(auth): OIDC role mapping + login policy — phase 2 (#798) (#854)

* 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:
Paul Nothaft
2026-07-22 20:59:19 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent ad326da35c
commit f8a95d29d2
13 changed files with 1017 additions and 34 deletions
+56 -9
View File
@@ -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) {
+18 -1
View File
@@ -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';
+7
View File
@@ -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',