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
+ {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 && ( + <> ++ {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) => ( ++ {t('adminLogin.ssoOnlyHint', 'Password login is disabled on this instance — sign in through your identity provider.')} +
+ } + onClick={() => { window.location.href = buildResourceUrl('/api/auth/admin/sso/login'); }} + > + {settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')} + +