diff --git a/.env.example b/.env.example index eb80a295..5c7b4b62 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,14 @@ NODE_ENV=production # Generate one with: openssl rand -base64 64 #JWT_SECRET=your_very_long_random_jwt_secret_here +# OIDC SSO for admins (#798) — configured in the admin UI; only these two +# values live in the environment: +# Key encrypting the OIDC client secret at rest (defaults to JWT_SECRET). +#OIDC_ENCRYPTION_KEY= +# Break-glass: 'true' re-enables local password login even while the SSO +# settings disable it (recovery when the IdP is down or misconfigured). +#OIDC_BREAK_GLASS=false + # Auth cookie Secure flag # unset - default: follows NODE_ENV (production=true, dev=false) # true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access) diff --git a/backend/__tests__/integration/oidcRoleMappingPolicy.test.js b/backend/__tests__/integration/oidcRoleMappingPolicy.test.js new file mode 100644 index 00000000..b75ba8e0 --- /dev/null +++ b/backend/__tests__/integration/oidcRoleMappingPolicy.test.js @@ -0,0 +1,416 @@ +/** + * OIDC role mapping + login policy integration tests (#798, phase 2). + * + * Same harness as oidcSso.test.js: supertest over the real routes, mock + * in-process IdP with genuine RS256/PKCE validation, fresh-SQLite DB. Pins: + * + * - JIT provisioning takes the MAPPED role from a nested dot-path claim + * (Keycloak's realm_access.roles), not the static default + * - roles are re-evaluated on every SSO login (upgrade AND downgrade) + * - several mapped roles → the highest-priority one wins + * - non-strict: unmapped login keeps the current role / default at JIT + * - strict (require_mapped_role): unmapped login → sso_error=no_role + * - the last active super_admin is never demoted by mapping + * - space-separated string claim values work (flat `roles` claim) + * - disable_local_login: password login → 403; OIDC_BREAK_GLASS=true + * re-opens it; flag is inert while SSO is disabled + * - PUT /sso validation: unknown mapping target and + * disable-local-login-without-SSO are rejected + */ + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); + +const { bootCrmDb } = require('./helpers/crmDb'); +const { MockOidcProvider } = require('./helpers/mockOidcProvider'); + +describe('OIDC role mapping + login policy (#798 phase 2)', () => { + let db; + let cleanup; + let app; + let idp; + let oidcService; + let superAdminToken; + + beforeAll(async () => { + process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret'; + process.env.FRONTEND_URL = 'http://localhost:5199'; + delete process.env.OIDC_BREAK_GLASS; + ({ db, cleanup } = await bootCrmDb()); + + idp = new MockOidcProvider(); + const issuer = await idp.start(); + + oidcService = require('../../src/services/oidcService'); + await oidcService.saveOidcSettings({ + oidc_enabled: true, + oidc_issuer_url: issuer, + oidc_client_id: idp.clientId, + oidc_client_secret: idp.clientSecret, + oidc_autoprovision: true, + oidc_default_role: 'viewer', + oidc_role_mapping_enabled: true, + oidc_roles_claim: 'realm_access.roles', + oidc_role_mappings: { + 'pp-super': 'super_admin', + 'pp-admins': 'admin', + 'pp-view': 'viewer', + }, + }); + + const authRouter = require('../../src/routes/auth'); + const adminSettingsRouter = require('../../src/routes/adminSettings'); + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/auth', authRouter); + app.use('/api/admin/settings', adminSettingsRouter); + + // A real super_admin row + token for the settings-validation tests. + const superRole = await db('roles').where({ name: 'super_admin' }).first(); + const [rootId] = await db('admin_users').insert({ + username: 'root-admin', + email: 'root@example.com', + password_hash: await bcrypt.hash('RootPass123', 4), + role_id: superRole.id, + is_active: 1, + auth_provider: 'local', + created_at: new Date(), + updated_at: new Date(), + }).returning('id').then((r) => [r[0]?.id || r[0]]); + superAdminToken = jwt.sign( + { id: rootId, username: 'root-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() }, + process.env.JWT_SECRET, + { expiresIn: '1h', issuer: 'picpeak-auth' } + ); + }, 120000); + + afterAll(async () => { + delete process.env.OIDC_BREAK_GLASS; + if (idp) await idp.stop(); + if (cleanup) await cleanup(); + }); + + /** Drive login → IdP → callback like a browser; returns the callback response. */ + async function ssoRoundTrip() { + const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302); + const stateCookie = (loginRes.headers['set-cookie'] || []) + .find((c) => c.startsWith('oidc_state=')).split(';')[0]; + const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' }); + expect(idpRes.status).toBe(302); + const back = new URL(idpRes.headers.get('location')); + return request(app) + .get(`${back.pathname}?${back.searchParams.toString()}`) + .set('Cookie', stateCookie) + .expect(302); + } + + async function roleOf(email) { + const row = await db('admin_users').where({ email }).first(); + const role = await db('roles').where({ id: row.role_id }).first(); + return role.name; + } + + it('JIT-provisions with the role mapped from the nested dot-path claim', async () => { + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['irrelevant', 'pp-admins'] }, + }); + const res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('mapped@example.com')).toBe('admin'); + }); + + it('re-evaluates the role on every login — downgrade lands', async () => { + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['pp-view'] }, + }); + const res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('mapped@example.com')).toBe('viewer'); + }); + + it('re-evaluates the role on every login — upgrade lands and the session JWT carries it', async () => { + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['pp-admins'] }, + }); + const res = await ssoRoundTrip(); + expect(await roleOf('mapped@example.com')).toBe('admin'); + + // The freshly-minted session token must already carry the NEW role — + // the sync happens before session establishment. + const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token=')); + const token = decodeURIComponent(adminCookie.split(';')[0].replace('admin_token=', '')); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' }); + expect(decoded.role).toBe('admin'); + }); + + it('picks the highest-priority role when several IdP values map', async () => { + idp.setNextUser({ + sub: 'sub-multi', + email: 'multi@example.com', + email_verified: true, + realm_access: { roles: ['pp-view', 'pp-admins'] }, + }); + await ssoRoundTrip(); + expect(await roleOf('multi@example.com')).toBe('admin'); + }); + + it('non-strict: an unmapped login keeps the current role / gets the default at JIT', async () => { + // Existing admin keeps its role. + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['nothing-mapped'] }, + }); + let res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('mapped@example.com')).toBe('admin'); + + // JIT falls back to the configured default role. + idp.setNextUser({ + sub: 'sub-unmapped-jit', + email: 'unmapped@example.com', + email_verified: true, + realm_access: { roles: ['nothing-mapped'] }, + }); + res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('unmapped@example.com')).toBe('viewer'); + }); + + it('strict mode refuses unmapped logins with sso_error=no_role and no session', async () => { + await oidcService.saveOidcSettings({ oidc_require_mapped_role: true }); + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['nothing-mapped'] }, + }); + const res = await ssoRoundTrip(); + await oidcService.saveOidcSettings({ oidc_require_mapped_role: false }); + + expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=no_role'); + expect((res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='))).toBeFalsy(); + // Role untouched by the refused attempt. + expect(await roleOf('mapped@example.com')).toBe('admin'); + }); + + it('never demotes the last active super_admin', async () => { + // Make the SSO admin the ONLY active super_admin. + const superRole = await db('roles').where({ name: 'super_admin' }).first(); + const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first(); + await db('admin_users').where({ role_id: superRole.id }).update({ is_active: 0 }); + await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id, is_active: 1 }); + + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['pp-view'] }, + }); + const res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + // Still super_admin — the demotion was refused, the login was not. + expect(await roleOf('mapped@example.com')).toBe('super_admin'); + + // Restore: root admin back to active super_admin, SSO admin back to admin. + const adminRole = await db('roles').where({ name: 'admin' }).first(); + await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 }); + await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: adminRole.id }); + + // With ANOTHER active super_admin present the same downgrade goes through. + idp.setNextUser({ + sub: 'sub-map-1', + email: 'mapped@example.com', + email_verified: true, + realm_access: { roles: ['pp-view'] }, + }); + await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id }); + await ssoRoundTrip(); + expect(await roleOf('mapped@example.com')).toBe('viewer'); + }); + + it('never demotes the last LOCAL-password super_admin even when an OIDC-owned super exists', async () => { + const superRole = await db('roles').where({ name: 'super_admin' }).first(); + const viewerRole = await db('roles').where({ name: 'viewer' }).first(); + + // A local-password super admin, SSO-linked via verified email so role + // sync applies to it. + const [localId] = await db('admin_users').insert({ + username: 'local-super', + email: 'local-super@example.com', + password_hash: await bcrypt.hash('LocalSuper123', 4), + role_id: superRole.id, + is_active: 1, + auth_provider: 'local', + created_at: new Date(), + updated_at: new Date(), + }).returning('id').then((r) => [r[0]?.id || r[0]]); + + // The only OTHER active super is OIDC-owned (root goes inactive) — the + // plain last-super guard would allow the demotion, the break-glass + // guard must not. + const ssoAdmin = await db('admin_users').where({ email: 'mapped@example.com' }).first(); + await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id }); + await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 0 }); + + idp.setNextUser({ + sub: 'sub-local-super', + email: 'local-super@example.com', + email_verified: true, + realm_access: { roles: ['pp-view'] }, + }); + const res = await ssoRoundTrip(); + + const row = await db('admin_users').where({ id: localId }).first(); + // Restore the fixture state before asserting. + await db('admin_users').where({ email: 'root@example.com' }).update({ is_active: 1 }); + await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: viewerRole.id }); + await db('admin_users').where({ id: localId }).update({ is_active: 0 }); + + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(row.role_id).toBe(superRole.id); // kept — it is the break-glass account + }); + + it('treats prototype-property IdP values (constructor/toString) as unmapped, not as an error', async () => { + idp.setNextUser({ + sub: 'sub-proto', + email: 'proto@example.com', + email_verified: true, + realm_access: { roles: ['constructor', 'toString', '__proto__'] }, + }); + const res = await ssoRoundTrip(); + // Non-strict: unmapped → JIT with the default role, login succeeds. + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('proto@example.com')).toBe('viewer'); + }); + + it('accepts a space-separated string value on a flat claim', async () => { + await oidcService.saveOidcSettings({ oidc_roles_claim: 'roles' }); + idp.setNextUser({ + sub: 'sub-flat', + email: 'flat@example.com', + email_verified: true, + roles: 'other pp-admins', + }); + const res = await ssoRoundTrip(); + await oidcService.saveOidcSettings({ oidc_roles_claim: 'realm_access.roles' }); + + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + expect(await roleOf('flat@example.com')).toBe('admin'); + }); + + it('refuses local password login while disable_local_login is effective', async () => { + await oidcService.saveOidcSettings({ oidc_disable_local_login: true }); + const res = await request(app) + .post('/api/auth/admin/login') + .send({ username: 'root@example.com', password: 'RootPass123' }); + expect(res.status).toBe(403); + expect(res.body.code).toBe('LOCAL_LOGIN_DISABLED'); + }); + + it('OIDC_BREAK_GLASS=true re-opens local login despite the policy', async () => { + process.env.OIDC_BREAK_GLASS = 'true'; + const res = await request(app) + .post('/api/auth/admin/login') + .send({ username: 'root@example.com', password: 'RootPass123' }); + delete process.env.OIDC_BREAK_GLASS; + expect(res.status).toBe(200); + expect(res.body.user).toBeTruthy(); + }); + + it('the stored flag is inert while SSO is disabled', async () => { + // Simulate a torn-down SSO config with the stale flag still set — the + // runtime check must ignore it (no lockout). + await db('app_settings').where({ setting_key: 'oidc_enabled' }) + .update({ setting_value: JSON.stringify(false) }); + expect(await oidcService.isLocalLoginDisabled()).toBe(false); + await db('app_settings').where({ setting_key: 'oidc_enabled' }) + .update({ setting_value: JSON.stringify(true) }); + expect(await oidcService.isLocalLoginDisabled()).toBe(true); + await oidcService.saveOidcSettings({ oidc_disable_local_login: false }); + }); + + it('the policy disarms itself when no active local-password super admin remains', async () => { + await oidcService.saveOidcSettings({ oidc_disable_local_login: true }); + expect(await oidcService.isLocalLoginDisabled()).toBe(true); + // The break-glass account disappears (e.g. manual demotion/deactivation + // while the policy is on) → local login must re-open by itself. + await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'oidc' }); + expect(await oidcService.isLocalLoginDisabled()).toBe(false); + await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' }); + await oidcService.saveOidcSettings({ oidc_disable_local_login: false }); + }); + + it('PUT /sso rejects a mapping onto an unknown role', async () => { + const res = await request(app) + .put('/api/admin/settings/sso') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ oidc_role_mappings: { 'pp-admins': 'does_not_exist' } }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/does_not_exist/); + // Stored mapping unchanged. + const cfg = await oidcService.getOidcConfig(); + expect(cfg.roleMappings['pp-admins']).toBe('admin'); + }); + + it('PUT /sso rejects disabling local login while SSO is (being turned) off', async () => { + const res = await request(app) + .put('/api/admin/settings/sso') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ oidc_enabled: false, oidc_disable_local_login: true }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/while SSO is enabled/); + }); + + it('PUT /sso refuses SSO-only mode without an active local-password super admin', async () => { + // Make every active super_admin OIDC-owned — break-glass would then + // re-open a password route that no account can use. + const superRole = await db('roles').where({ name: 'super_admin' }).first(); + await db('admin_users').where({ role_id: superRole.id }).update({ auth_provider: 'oidc' }); + const denied = await request(app) + .put('/api/admin/settings/sso') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ oidc_disable_local_login: true }); + // Restore the local break-glass account, then the same request passes. + await db('admin_users').where({ email: 'root@example.com' }).update({ auth_provider: 'local' }); + expect(denied.status).toBe(400); + expect(denied.body.error).toMatch(/break-glass/); + + const allowed = await request(app) + .put('/api/admin/settings/sso') + .set('Authorization', `Bearer ${superAdminToken}`) + .send({ oidc_disable_local_login: true }); + expect(allowed.status).toBe(200); + await oidcService.saveOidcSettings({ oidc_disable_local_login: false }); + }); + + it('GET /sso returns the phase-2 fields', async () => { + const res = await request(app) + .get('/api/admin/settings/sso') + .set('Authorization', `Bearer ${superAdminToken}`); + expect(res.status).toBe(200); + expect(res.body.oidc_role_mapping_enabled).toBe(true); + expect(res.body.oidc_roles_claim).toBe('realm_access.roles'); + expect(res.body.oidc_role_mappings).toEqual({ + 'pp-super': 'super_admin', + 'pp-admins': 'admin', + 'pp-view': 'viewer', + }); + expect(res.body.oidc_require_mapped_role).toBe(false); + expect(res.body.oidc_disable_local_login).toBe(false); + }); +}); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index fc5981fc..3de13821 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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) { diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index ba3e1b56..0674b610 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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'; diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 70d5c49a..2b1c0a41 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -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', diff --git a/backend/src/services/oidcService.js b/backend/src/services/oidcService.js index ccf90288..696c13c3 100644 --- a/backend/src/services/oidcService.js +++ b/backend/src/services/oidcService.js @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml index 46b56fef..ceea2dcb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,11 @@ services: # Public API origin for split-origin deployments (#798 SSO redirect_uri). # Empty = same origin as FRONTEND_URL (the standard proxied setup). - API_URL=${API_URL:-} + # OIDC SSO (#798): key for the client secret at rest (falls back to + # JWT_SECRET) and the break-glass override that re-enables local + # password login when the IdP is down while SSO-only mode is active. + - OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-} + - OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-} - ADMIN_URL=${ADMIN_URL:-http://localhost:3001} - TZ=${TZ:-UTC} - STORAGE_PATH=/app/storage diff --git a/frontend/src/features/settings/tabs/SsoTab.tsx b/frontend/src/features/settings/tabs/SsoTab.tsx index 1a662191..57eba2e7 100644 --- a/frontend/src/features/settings/tabs/SsoTab.tsx +++ b/frontend/src/features/settings/tabs/SsoTab.tsx @@ -2,21 +2,39 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; -import { Save, KeyRound, PlugZap, Copy, Check } from 'lucide-react'; +import { Save, KeyRound, PlugZap, Copy, Check, UserCog, ShieldAlert, Plus, Trash2 } from 'lucide-react'; import type { AxiosError } from 'axios'; import { Button, Card, Input, Loading } from '../../../components/common'; import { ssoService, SsoSettings, UpdateSsoSettings } from '../../../services/sso.service'; -// SSO (OIDC) settings (#798, phase 1). Deliberately lean: issuer + client -// credentials, JIT toggle with default role, button label. Role-claim -// mapping is a follow-up. The client secret is write-only — the field stays -// blank and only overwrites when the admin types a new value. +interface MappingRow { + idpRole: string; + role: string; +} + +// The four system roles — same hardcoded set the default-role select uses. +// The backend re-validates every mapping target against the roles table. +const ROLE_OPTIONS: Record = { + viewer: 'Viewer', + editor: 'Editor', + admin: 'Admin', + super_admin: 'Super Admin', +}; + +// SSO (OIDC) settings (#798). Phase 1: issuer + client credentials, JIT +// toggle with default role, button label. Phase 2: role-claim mapping and +// login policy (require mapped role / disable local login). The client +// secret is write-only — the field stays blank and only overwrites when the +// admin types a new value. export const SsoTab: React.FC = () => { const { t } = useTranslation(); const queryClient = useQueryClient(); const [form, setForm] = useState(null); + // Row-based editor state for the { idpRole: picpeakRole } mapping object — + // an object can't represent a half-typed key, rows can. + const [mappingRows, setMappingRows] = useState(null); const [newSecret, setNewSecret] = useState(''); const [copied, setCopied] = useState(false); @@ -25,6 +43,8 @@ export const SsoTab: React.FC = () => { queryFn: async () => { const settings = await ssoService.getSettings(); setForm((prev) => prev ?? settings); + setMappingRows((prev) => prev + ?? Object.entries(settings.oidc_role_mappings || {}).map(([idpRole, role]) => ({ idpRole, role }))); return settings; }, }); @@ -35,6 +55,7 @@ export const SsoTab: React.FC = () => { toast.success(t('settings.sso.saved', 'SSO settings saved')); setNewSecret(''); setForm(null); // re-init from the fresh GET (secret_set flag updates) + setMappingRows(null); queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] }); queryClient.invalidateQueries({ queryKey: ['public-settings'] }); }, @@ -57,13 +78,16 @@ export const SsoTab: React.FC = () => { }, }); - if (isLoading || !form) { + if (isLoading || !form || !mappingRows) { return ; } const set = (key: K, value: SsoSettings[K]) => setForm((prev) => (prev ? { ...prev, [key]: value } : prev)); + const setRow = (index: number, patch: Partial) => + setMappingRows((prev) => (prev ? prev.map((row, i) => (i === index ? { ...row, ...patch } : row)) : prev)); + const handleSave = () => { const payload: UpdateSsoSettings = { oidc_enabled: form.oidc_enabled, @@ -73,6 +97,16 @@ export const SsoTab: React.FC = () => { oidc_default_role: form.oidc_default_role, oidc_button_label: form.oidc_button_label.trim(), oidc_scopes: form.oidc_scopes.trim(), + oidc_role_mapping_enabled: form.oidc_role_mapping_enabled, + oidc_roles_claim: form.oidc_roles_claim.trim(), + oidc_role_mappings: Object.fromEntries( + mappingRows.filter((row) => row.idpRole.trim()).map((row) => [row.idpRole.trim(), row.role]) + ), + oidc_require_mapped_role: form.oidc_require_mapped_role, + // Turning SSO off must clear the policy in the same save — the backend + // (rightly) rejects an explicit true while SSO is off, and the promise + // is that disabling SSO restores password login. + oidc_disable_local_login: form.oidc_enabled ? form.oidc_disable_local_login : false, }; if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim(); saveMutation.mutate(payload); @@ -244,6 +278,154 @@ export const SsoTab: React.FC = () => {

+ + {/* Role mapping (#798 phase 2) */} + +
+
+ +

+ {t('settings.sso.roleMapping.title', 'Role mapping')} +

+
+

+ {t('settings.sso.roleMapping.intro', 'Assign PicPeak roles from a role or group claim in the ID token. Roles are re-evaluated on every SSO login — the IdP becomes the source of truth.')} +

+ + + + {form.oidc_role_mapping_enabled && ( + <> +
+ set('oidc_roles_claim', e.target.value)} + /> +

+ {t('settings.sso.roleMapping.claimHint', 'Keycloak: realm_access.roles · Authentik: groups · Entra ID: roles or groups')} +

+
+ +
+

+ {t('settings.sso.roleMapping.mappings', 'Mappings (IdP value → PicPeak role)')} +

+ {mappingRows.length === 0 && ( +

+ {t('settings.sso.roleMapping.noMappings', 'No mappings yet — without one, no login gets a role from the IdP.')} +

+ )} + {mappingRows.map((row, index) => ( +
+
+ setRow(index, { idpRole: e.target.value })} + /> +
+ + +
+ ))} + +
+ + + + )} +
+
+ + {/* Login policy (#798 phase 2) */} + +
+
+ +

+ {t('settings.sso.policy.title', 'Login policy')} +

+
+ + + + {form.oidc_disable_local_login && ( +
+ {t('settings.sso.policy.breakGlassHint', 'Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately.')} +
+ )} +
+
); }; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 66fdb25d..a1bba566 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2119,7 +2119,27 @@ "testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}", "testFailed": "Discovery fehlgeschlagen", "saved": "SSO-Einstellungen gespeichert", - "saveError": "SSO-Einstellungen konnten nicht gespeichert werden" + "saveError": "SSO-Einstellungen konnten nicht gespeichert werden", + "roleMapping": { + "title": "Rollen-Mapping", + "intro": "Weisen Sie PicPeak-Rollen aus einem Rollen- oder Gruppen-Claim im ID-Token zu. Rollen werden bei jeder SSO-Anmeldung neu ausgewertet — der IdP wird zur führenden Quelle.", + "enabled": "Rollen-Mapping aktivieren", + "enabledHint": "Aus: Bestehende Admins behalten ihre Rolle, neue Benutzer erhalten die Standardrolle oben.", + "claim": "Rollen-Claim (Punkt-Pfad)", + "claimHint": "Keycloak: realm_access.roles · Authentik: groups · Entra ID: roles oder groups", + "mappings": "Zuordnungen (IdP-Wert → PicPeak-Rolle)", + "noMappings": "Noch keine Zuordnungen — ohne Zuordnung erhält keine Anmeldung eine Rolle vom IdP.", + "idpValuePlaceholder": "IdP-Rolle oder -Gruppe, z. B. picpeak-admins", + "addMapping": "Zuordnung hinzufügen", + "requireRole": "Zugeordnete Rolle für die Anmeldung verlangen", + "requireRoleHint": "SSO-Anmeldungen ohne zugeordnete Rolle werden abgelehnt — nur Mitglieder der zugeordneten IdP-Gruppen kommen hinein. Der letzte aktive Super Admin wird durch das Mapping nie herabgestuft." + }, + "policy": { + "title": "Anmelde-Richtlinie", + "disableLocalLogin": "Lokale Passwort-Anmeldung deaktivieren", + "disableLocalLoginHint": "Die Anmeldeseite zeigt nur noch den SSO-Button und die API lehnt Passwort-Anmeldungen ab. Nur möglich, solange SSO aktiviert ist; wird SSO deaktiviert, ist die Passwort-Anmeldung automatisch wieder möglich.", + "breakGlassHint": "Ausgesperrt, weil der IdP down oder falsch konfiguriert ist? Setzen Sie die Umgebungsvariable OIDC_BREAK_GLASS=true am Backend und starten Sie neu — die Passwort-Anmeldung ist sofort wieder möglich." + } } }, "branding": { @@ -3749,13 +3769,15 @@ }, "ssoDivider": "oder", "ssoSignIn": "Mit SSO anmelden", + "ssoOnlyHint": "Die Passwort-Anmeldung ist auf dieser Instanz deaktiviert — melden Sie sich über Ihren Identity Provider an.", "ssoErrors": { "config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.", "state": "Die SSO-Anmeldung ist abgelaufen oder wurde manipuliert. Bitte erneut versuchen.", "idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.", "inactive": "Ihr Admin-Konto ist deaktiviert.", "not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.", - "no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden." + "no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden.", + "no_role": "Ihr Identity Provider hat keine Rolle geliefert, die dieser Instanz zugeordnet ist — bitten Sie einen Administrator, Sie einer zugeordneten Gruppe hinzuzufügen." } }, "cssTemplates": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0aa24dcc..0a7fcf69 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1666,7 +1666,27 @@ "testOk": "Discovery succeeded — issuer is reachable: {{issuer}}", "testFailed": "Discovery failed", "saved": "SSO settings saved", - "saveError": "Failed to save SSO settings" + "saveError": "Failed to save SSO settings", + "roleMapping": { + "title": "Role mapping", + "intro": "Assign PicPeak roles from a role or group claim in the ID token. Roles are re-evaluated on every SSO login — the IdP becomes the source of truth.", + "enabled": "Enable role mapping", + "enabledHint": "Off: existing admins keep their role and new users get the default role above.", + "claim": "Roles claim (dot-path)", + "claimHint": "Keycloak: realm_access.roles · Authentik: groups · Entra ID: roles or groups", + "mappings": "Mappings (IdP value → PicPeak role)", + "noMappings": "No mappings yet — without one, no login gets a role from the IdP.", + "idpValuePlaceholder": "IdP role or group, e.g. picpeak-admins", + "addMapping": "Add mapping", + "requireRole": "Require a mapped role to sign in", + "requireRoleHint": "Refuse SSO logins whose token maps to no role — only members of the mapped IdP groups get in. The last active Super Admin is never demoted by mapping." + }, + "policy": { + "title": "Login policy", + "disableLocalLogin": "Disable local password login", + "disableLocalLoginHint": "The login page shows only the SSO button and the API refuses password logins. Only possible while SSO is enabled; turning SSO off restores password login automatically.", + "breakGlassHint": "Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately." + } } }, "analytics": { @@ -3635,13 +3655,15 @@ }, "ssoDivider": "or", "ssoSignIn": "Sign in with SSO", + "ssoOnlyHint": "Password login is disabled on this instance — sign in through your identity provider.", "ssoErrors": { "config": "SSO is misconfigured — check the SSO settings or sign in with email and password.", "state": "The SSO sign-in expired or was tampered with. Please try again.", "idp": "The identity provider rejected the sign-in. Please try again or use email and password.", "inactive": "Your admin account is deactivated.", "not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.", - "no_email": "Your identity provider supplied no email address — an account cannot be created." + "no_email": "Your identity provider supplied no email address — an account cannot be created.", + "no_role": "Your identity provider granted no role that is mapped to this instance — ask an administrator to add you to a mapped group." } }, "slideshow": { diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 4a5f3fda..b650aaff 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -67,7 +67,7 @@ export const AdminLoginPage: React.FC = () => { useEffect(() => { const ssoError = searchParams.get('sso_error'); if (!ssoError) return; - const known = ['config', 'state', 'idp', 'inactive', 'not_provisioned', 'no_email']; + const known = ['config', 'state', 'idp', 'inactive', 'not_provisioned', 'no_email', 'no_role']; const key = known.includes(ssoError) ? ssoError : 'idp'; toast.error(t(`adminLogin.ssoErrors.${key}`)); }, [searchParams, t]); @@ -262,7 +262,26 @@ export const AdminLoginPage: React.FC = () => { {/* Login Form */} - {step === 'credentials' ? ( + {step === 'credentials' && settingsData?.oidc_enabled === true && settingsData?.oidc_local_login_disabled === true ? ( + // SSO-only mode (#798 phase 2): the backend refuses password logins + // while oidc_disable_local_login is effective, so the form would + // only produce 403s — show the SSO entry alone instead. +
+

+ {t('adminLogin.ssoOnlyHint', 'Password login is disabled on this instance — sign in through your identity provider.')} +

+ +
+ ) : step === 'credentials' ? (
{/* Form Error */} {errors.form && ( diff --git a/frontend/src/services/publicSettings.service.ts b/frontend/src/services/publicSettings.service.ts index f7e461fe..e18c7028 100644 --- a/frontend/src/services/publicSettings.service.ts +++ b/frontend/src/services/publicSettings.service.ts @@ -102,6 +102,8 @@ export interface PublicSettings { // OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login. oidc_enabled?: boolean; oidc_button_label?: string; + /** Effective flag (phase 2): the API refuses password logins right now. */ + oidc_local_login_disabled?: boolean; } export const publicSettingsService = { diff --git a/frontend/src/services/sso.service.ts b/frontend/src/services/sso.service.ts index 4e4101f7..564f5455 100644 --- a/frontend/src/services/sso.service.ts +++ b/frontend/src/services/sso.service.ts @@ -15,6 +15,12 @@ export interface SsoSettings { oidc_default_role: string; oidc_button_label: string; oidc_scopes: string; + oidc_role_mapping_enabled: boolean; + oidc_roles_claim: string; + /** IdP role/group value → PicPeak role name. */ + oidc_role_mappings: Record; + oidc_require_mapped_role: boolean; + oidc_disable_local_login: boolean; redirect_uri: string; } @@ -28,6 +34,11 @@ export interface UpdateSsoSettings { oidc_default_role?: string; oidc_button_label?: string; oidc_scopes?: string; + oidc_role_mapping_enabled?: boolean; + oidc_roles_claim?: string; + oidc_role_mappings?: Record; + oidc_require_mapped_role?: boolean; + oidc_disable_local_login?: boolean; } export interface SsoTestResult {