* feat(auth): OIDC role mapping + login policy — phase 2 (#798) Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles, Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping table validated against the roles table, re-evaluated on every SSO login with highest-priority-wins on multiple matches. The last active super_admin is never demoted. Optional require-mapped-role policy refuses logins whose token maps to no role (sso_error=no_role). Login policy: oidc_disable_local_login makes the API refuse password logins (403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens local login. Public settings expose the EFFECTIVE flag only. Settings UI: Role-mapping card (claim path, mapping rows editor, strict toggle) and Login-policy card with break-glass hint, EN+DE. 14 new integration tests over the mock IdP. * fix(auth): harden phase-2 review findings (#798) - memoize the scrypt-derived OIDC key and serve /public/settings from a 10s-TTL flag cache — the unauthenticated endpoint no longer pays a 13-key config read + blocking scryptSync per request (login route still checks uncached) - make the last-super-admin demotion guard atomic (FOR UPDATE on the active super rows) — concurrent mapped callbacks could previously both count 2 and demote both supers - own-property lookup in role mapping: IdP values like `constructor` now count as unmapped instead of corrupting the roles query - SsoTab clears oidc_disable_local_login in the same save that turns SSO off — the full-form payload otherwise hit the server-side 400 * fix(auth): guarantee break-glass reachability for SSO-only mode (#798) - wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start docker-compose.yml env allowlist (production compose already passes .env via env_file) and document both in .env.example - refuse enabling oidc_disable_local_login unless an active local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the password route, which OIDC-owned accounts can never use, and settings.edit is super_admin-only — an all-OIDC instance would be unrecoverable during an IdP outage * fix(auth): close SSO-only lockout gaps from review round 3 (#798) - role sync never demotes the last active LOCAL-password super_admin (an OIDC-owned super does not count as break-glass), and isLocalLoginDisabled() disarms itself when no such account remains — self-healing against manual demotion/deactivation/deletion paths - the local-super save-time check now validates the MERGED state, so re-enabling SSO with a stored disable flag is checked too - ALL oidc_* keys are reserved from the generic settings upserts/reads (prefix match) — policy and mapping invariants can only go through the validated PUT /sso - /admin/login/mfa re-checks the policy so an mfa_pending token minted before the flip cannot complete into a local session --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
ad326da35c
commit
f8a95d29d2
@@ -10,6 +10,14 @@ NODE_ENV=production
|
|||||||
# Generate one with: openssl rand -base64 64
|
# Generate one with: openssl rand -base64 64
|
||||||
#JWT_SECRET=your_very_long_random_jwt_secret_here
|
#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
|
# Auth cookie Secure flag
|
||||||
# unset - default: follows NODE_ENV (production=true, dev=false)
|
# unset - default: follows NODE_ENV (production=true, dev=false)
|
||||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
|
||||||
|
|||||||
@@ -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: '[email protected]',
|
||||||
|
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: '[email protected]',
|
||||||
|
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('[email protected]')).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-evaluates the role on every login — downgrade lands', async () => {
|
||||||
|
idp.setNextUser({
|
||||||
|
sub: 'sub-map-1',
|
||||||
|
email: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]',
|
||||||
|
email_verified: true,
|
||||||
|
realm_access: { roles: ['pp-admins'] },
|
||||||
|
});
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(await roleOf('[email protected]')).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: '[email protected]',
|
||||||
|
email_verified: true,
|
||||||
|
realm_access: { roles: ['pp-view', 'pp-admins'] },
|
||||||
|
});
|
||||||
|
await ssoRoundTrip();
|
||||||
|
expect(await roleOf('[email protected]')).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: '[email protected]',
|
||||||
|
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('[email protected]')).toBe('admin');
|
||||||
|
|
||||||
|
// JIT falls back to the configured default role.
|
||||||
|
idp.setNextUser({
|
||||||
|
sub: 'sub-unmapped-jit',
|
||||||
|
email: '[email protected]',
|
||||||
|
email_verified: true,
|
||||||
|
realm_access: { roles: ['nothing-mapped'] },
|
||||||
|
});
|
||||||
|
res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||||
|
expect(await roleOf('[email protected]')).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: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]' }).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: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]' }).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: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]',
|
||||||
|
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: '[email protected]' }).first();
|
||||||
|
await db('admin_users').where({ id: ssoAdmin.id }).update({ role_id: superRole.id });
|
||||||
|
await db('admin_users').where({ email: '[email protected]' }).update({ is_active: 0 });
|
||||||
|
|
||||||
|
idp.setNextUser({
|
||||||
|
sub: 'sub-local-super',
|
||||||
|
email: '[email protected]',
|
||||||
|
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: '[email protected]' }).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: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]',
|
||||||
|
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('[email protected]')).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: '[email protected]', 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: '[email protected]', 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: '[email protected]' }).update({ auth_provider: 'oidc' });
|
||||||
|
expect(await oidcService.isLocalLoginDisabled()).toBe(false);
|
||||||
|
await db('admin_users').where({ email: '[email protected]' }).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: '[email protected]' }).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
// 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
|
// writable through PUT /sso below — a generic upsert would store plaintext
|
||||||
// and break decryption (#798).
|
// 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) => {
|
const stripReservedSettingKeys = (settings) => {
|
||||||
for (const key of RESERVED_SETTING_KEYS) {
|
for (const key of Object.keys(settings)) {
|
||||||
delete settings[key];
|
if (isReservedSettingKey(key)) delete settings[key];
|
||||||
}
|
}
|
||||||
return settings;
|
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
|
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||||
for (const key of RESERVED_SETTING_KEYS) {
|
stripReservedSettingKeys(settingsObject);
|
||||||
delete settingsObject[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask sensitive secrets before sending to client
|
// Mask sensitive secrets before sending to client
|
||||||
if (settingsObject.security_recaptcha_secret_key) {
|
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_default_role: cfg.defaultRole,
|
||||||
oidc_button_label: cfg.buttonLabel || '',
|
oidc_button_label: cfg.buttonLabel || '',
|
||||||
oidc_scopes: cfg.scopes,
|
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,
|
redirect_uri: redirectUri,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -453,6 +461,11 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [
|
|||||||
body('oidc_default_role').optional().isString().trim(),
|
body('oidc_default_role').optional().isString().trim(),
|
||||||
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
|
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
|
||||||
body('oidc_scopes').optional().isString().trim(),
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
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 oidcService.saveOidcSettings(req.body);
|
||||||
|
|
||||||
await logActivity('sso_settings_updated',
|
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
|
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||||
for (const key of RESERVED_SETTING_KEYS) {
|
stripReservedSettingKeys(settingsObject);
|
||||||
delete settingsObject[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask sensitive secrets before sending to client
|
// Mask sensitive secrets before sending to client
|
||||||
if (settingsObject.security_recaptcha_secret_key) {
|
if (settingsObject.security_recaptcha_secret_key) {
|
||||||
|
|||||||
@@ -106,7 +106,15 @@ router.post('/admin/login', [
|
|||||||
const { username, password, recaptchaToken } = req.body;
|
const { username, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = getClientIp(req);
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
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
|
// Check account lockout first
|
||||||
const lockoutStatus = await checkAccountLockout(username);
|
const lockoutStatus = await checkAccountLockout(username);
|
||||||
if (lockoutStatus.isLocked) {
|
if (lockoutStatus.isLocked) {
|
||||||
@@ -198,6 +206,14 @@ router.post('/admin/login/mfa', [
|
|||||||
const ipAddress = getClientIp(req);
|
const ipAddress = getClientIp(req);
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
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;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
decoded = jwt.verify(mfaToken, process.env.JWT_SECRET, {
|
||||||
@@ -1001,6 +1017,7 @@ router.get('/admin/sso/callback', async (req, res) => {
|
|||||||
OIDC_INACTIVE: 'inactive',
|
OIDC_INACTIVE: 'inactive',
|
||||||
OIDC_NOT_PROVISIONED: 'not_provisioned',
|
OIDC_NOT_PROVISIONED: 'not_provisioned',
|
||||||
OIDC_NO_EMAIL: 'no_email',
|
OIDC_NO_EMAIL: 'no_email',
|
||||||
|
OIDC_NO_ROLE: 'no_role',
|
||||||
OIDC_BAD_CLAIMS: 'idp',
|
OIDC_BAD_CLAIMS: 'idp',
|
||||||
};
|
};
|
||||||
const key = codeMap[error.code] || 'idp';
|
const key = codeMap[error.code] || 'idp';
|
||||||
|
|||||||
@@ -142,6 +142,13 @@ router.get('/', async (req, res) => {
|
|||||||
// SSO" button from these. Issuer/client/secret are never public.
|
// SSO" button from these. Issuer/client/secret are never public.
|
||||||
oidc_enabled: settingsObject.oidc_enabled === true,
|
oidc_enabled: settingsObject.oidc_enabled === true,
|
||||||
oidc_button_label: settingsObject.oidc_button_label || '',
|
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',
|
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
|
||||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||||
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* OIDC SSO for admin users (#798, phase 1).
|
* OIDC SSO for admin users (#798).
|
||||||
*
|
*
|
||||||
* Authorization-code + PKCE against a single configurable IdP (Keycloak,
|
* Authorization-code + PKCE against a single configurable IdP (Keycloak,
|
||||||
* Authentik, Pocket ID, or any spec-compliant provider). Scope is deliberately
|
* Authentik, Pocket ID, or any spec-compliant provider). Phase 1: admin
|
||||||
* narrow in phase 1: admin logins only, JIT provisioning with one default
|
* logins only, JIT provisioning with one default role. Phase 2: role-claim
|
||||||
* role. Role-claim mapping and logout-to-IdP are follow-ups.
|
* 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
|
* Identity binding: SSO logins match on `admin_users.external_subject` (the
|
||||||
* IdP's stable `sub` claim) — NEVER on email alone, which is an
|
* 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_ALGO = 'aes-256-gcm';
|
||||||
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
|
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() {
|
function getEncryptionKey() {
|
||||||
const material = process.env.OIDC_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
const material = process.env.OIDC_ENCRYPTION_KEY || process.env.JWT_SECRET;
|
||||||
if (!material) {
|
if (!material) {
|
||||||
throw new Error('oidcService: OIDC_ENCRYPTION_KEY or JWT_SECRET must be set');
|
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). */
|
/** 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.
|
* for internal use only; the settings GET endpoint must never call this.
|
||||||
*/
|
*/
|
||||||
async function getOidcConfig() {
|
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([
|
await Promise.all([
|
||||||
getAppSetting('oidc_enabled'),
|
getAppSetting('oidc_enabled'),
|
||||||
getAppSetting('oidc_issuer_url'),
|
getAppSetting('oidc_issuer_url'),
|
||||||
@@ -86,6 +99,11 @@ async function getOidcConfig() {
|
|||||||
getAppSetting('oidc_default_role'),
|
getAppSetting('oidc_default_role'),
|
||||||
getAppSetting('oidc_button_label'),
|
getAppSetting('oidc_button_label'),
|
||||||
getAppSetting('oidc_scopes'),
|
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;
|
let clientSecret = null;
|
||||||
@@ -110,9 +128,73 @@ async function getOidcConfig() {
|
|||||||
defaultRole: defaultRole || 'viewer',
|
defaultRole: defaultRole || 'viewer',
|
||||||
buttonLabel: buttonLabel || null,
|
buttonLabel: buttonLabel || null,
|
||||||
scopes: scopes || 'openid profile email',
|
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) {
|
function isConfigured(cfg) {
|
||||||
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
|
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
|
||||||
}
|
}
|
||||||
@@ -260,6 +342,109 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
|||||||
return claims;
|
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.
|
* Map validated ID token claims to an admin_users row.
|
||||||
*
|
*
|
||||||
@@ -289,6 +474,22 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
throw err;
|
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.
|
// 1. Established binding — issuer AND subject.
|
||||||
const bySub = await db('admin_users')
|
const bySub = await db('admin_users')
|
||||||
.where('external_issuer', iss)
|
.where('external_issuer', iss)
|
||||||
@@ -300,7 +501,7 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
err.code = 'OIDC_INACTIVE';
|
err.code = 'OIDC_INACTIVE';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return bySub;
|
return syncAdminRole(bySub, mappedRole);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. One-time email link — verified emails only, and only onto rows that
|
// 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_issuer', iss)
|
||||||
.where('external_subject', sub)
|
.where('external_subject', sub)
|
||||||
.first();
|
.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');
|
const err = new Error('Account link raced with another sign-in — try again');
|
||||||
err.code = 'OIDC_BAD_CLAIMS';
|
err.code = 'OIDC_BAD_CLAIMS';
|
||||||
throw err;
|
throw err;
|
||||||
@@ -345,12 +546,11 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
adminId: byEmail.id,
|
adminId: byEmail.id,
|
||||||
sub,
|
sub,
|
||||||
});
|
});
|
||||||
return { ...byEmail, external_issuer: iss, external_subject: sub };
|
return syncAdminRole({ ...byEmail, external_issuer: iss, external_subject: sub }, mappedRole);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. JIT provisioning.
|
// 3. JIT provisioning.
|
||||||
const cfg = await getOidcConfig();
|
|
||||||
if (!cfg.autoprovision) {
|
if (!cfg.autoprovision) {
|
||||||
const err = new Error('No matching admin account and auto-provisioning is disabled');
|
const err = new Error('No matching admin account and auto-provisioning is disabled');
|
||||||
err.code = 'OIDC_NOT_PROVISIONED';
|
err.code = 'OIDC_NOT_PROVISIONED';
|
||||||
@@ -362,7 +562,9 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
throw err;
|
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) {
|
if (!role) {
|
||||||
const err = new Error(`Configured default role '${cfg.defaultRole}' does not exist`);
|
const err = new Error(`Configured default role '${cfg.defaultRole}' does not exist`);
|
||||||
err.code = 'OIDC_BAD_CONFIG';
|
err.code = 'OIDC_BAD_CONFIG';
|
||||||
@@ -389,7 +591,7 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
})
|
})
|
||||||
.returning('id');
|
.returning('id');
|
||||||
const adminId = inserted[0]?.id || inserted[0];
|
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();
|
return db('admin_users').where('id', adminId).first();
|
||||||
}
|
}
|
||||||
@@ -417,17 +619,40 @@ async function saveOidcSettings(input) {
|
|||||||
if (!scopes.includes('openid')) scopes.unshift('openid');
|
if (!scopes.includes('openid')) scopes.unshift('openid');
|
||||||
put('oidc_scopes', scopes.join(' ') || 'openid profile email', 'string');
|
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) {
|
if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) {
|
||||||
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
|
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(writes);
|
await Promise.all(writes);
|
||||||
invalidateDiscoveryCache();
|
invalidateDiscoveryCache();
|
||||||
|
_localLoginFlagCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getOidcConfig,
|
getOidcConfig,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
|
isLocalLoginDisabled,
|
||||||
|
isLocalLoginDisabledCached,
|
||||||
|
hasActiveLocalSuperAdmin,
|
||||||
|
extractRolesFromClaims,
|
||||||
|
resolveMappedRole,
|
||||||
getRedirectUri,
|
getRedirectUri,
|
||||||
buildAuthorizationRequest,
|
buildAuthorizationRequest,
|
||||||
handleCallback,
|
handleCallback,
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ services:
|
|||||||
# Public API origin for split-origin deployments (#798 SSO redirect_uri).
|
# Public API origin for split-origin deployments (#798 SSO redirect_uri).
|
||||||
# Empty = same origin as FRONTEND_URL (the standard proxied setup).
|
# Empty = same origin as FRONTEND_URL (the standard proxied setup).
|
||||||
- API_URL=${API_URL:-}
|
- 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}
|
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
- STORAGE_PATH=/app/storage
|
- STORAGE_PATH=/app/storage
|
||||||
|
|||||||
@@ -2,21 +2,39 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
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 type { AxiosError } from 'axios';
|
||||||
|
|
||||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||||
import { ssoService, SsoSettings, UpdateSsoSettings } from '../../../services/sso.service';
|
import { ssoService, SsoSettings, UpdateSsoSettings } from '../../../services/sso.service';
|
||||||
|
|
||||||
// SSO (OIDC) settings (#798, phase 1). Deliberately lean: issuer + client
|
interface MappingRow {
|
||||||
// credentials, JIT toggle with default role, button label. Role-claim
|
idpRole: string;
|
||||||
// mapping is a follow-up. The client secret is write-only — the field stays
|
role: string;
|
||||||
// blank and only overwrites when the admin types a new value.
|
}
|
||||||
|
|
||||||
|
// 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<string, string> = {
|
||||||
|
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 = () => {
|
export const SsoTab: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [form, setForm] = useState<SsoSettings | null>(null);
|
const [form, setForm] = useState<SsoSettings | null>(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<MappingRow[] | null>(null);
|
||||||
const [newSecret, setNewSecret] = useState('');
|
const [newSecret, setNewSecret] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +43,8 @@ export const SsoTab: React.FC = () => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const settings = await ssoService.getSettings();
|
const settings = await ssoService.getSettings();
|
||||||
setForm((prev) => prev ?? settings);
|
setForm((prev) => prev ?? settings);
|
||||||
|
setMappingRows((prev) => prev
|
||||||
|
?? Object.entries(settings.oidc_role_mappings || {}).map(([idpRole, role]) => ({ idpRole, role })));
|
||||||
return settings;
|
return settings;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -35,6 +55,7 @@ export const SsoTab: React.FC = () => {
|
|||||||
toast.success(t('settings.sso.saved', 'SSO settings saved'));
|
toast.success(t('settings.sso.saved', 'SSO settings saved'));
|
||||||
setNewSecret('');
|
setNewSecret('');
|
||||||
setForm(null); // re-init from the fresh GET (secret_set flag updates)
|
setForm(null); // re-init from the fresh GET (secret_set flag updates)
|
||||||
|
setMappingRows(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||||
},
|
},
|
||||||
@@ -57,13 +78,16 @@ export const SsoTab: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !form) {
|
if (isLoading || !form || !mappingRows) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const set = <K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) =>
|
const set = <K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) =>
|
||||||
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
|
||||||
|
const setRow = (index: number, patch: Partial<MappingRow>) =>
|
||||||
|
setMappingRows((prev) => (prev ? prev.map((row, i) => (i === index ? { ...row, ...patch } : row)) : prev));
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
const payload: UpdateSsoSettings = {
|
const payload: UpdateSsoSettings = {
|
||||||
oidc_enabled: form.oidc_enabled,
|
oidc_enabled: form.oidc_enabled,
|
||||||
@@ -73,6 +97,16 @@ export const SsoTab: React.FC = () => {
|
|||||||
oidc_default_role: form.oidc_default_role,
|
oidc_default_role: form.oidc_default_role,
|
||||||
oidc_button_label: form.oidc_button_label.trim(),
|
oidc_button_label: form.oidc_button_label.trim(),
|
||||||
oidc_scopes: form.oidc_scopes.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();
|
if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim();
|
||||||
saveMutation.mutate(payload);
|
saveMutation.mutate(payload);
|
||||||
@@ -244,6 +278,154 @@ export const SsoTab: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Role mapping (#798 phase 2) */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCog className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.sso.roleMapping.title', 'Role mapping')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{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.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 pt-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||||
|
checked={form.oidc_role_mapping_enabled}
|
||||||
|
onChange={(e) => set('oidc_role_mapping_enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.sso.roleMapping.enabled', 'Enable role mapping')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.roleMapping.enabledHint', 'Off: existing admins keep their role and new users get the default role above.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.oidc_role_mapping_enabled && (
|
||||||
|
<>
|
||||||
|
<div className="max-w-md">
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.roleMapping.claim', 'Roles claim (dot-path)')}
|
||||||
|
placeholder="realm_access.roles"
|
||||||
|
value={form.oidc_roles_claim}
|
||||||
|
onChange={(e) => set('oidc_roles_claim', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.roleMapping.claimHint', 'Keycloak: realm_access.roles · Authentik: groups · Entra ID: roles or groups')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.sso.roleMapping.mappings', 'Mappings (IdP value → PicPeak role)')}
|
||||||
|
</p>
|
||||||
|
{mappingRows.length === 0 && (
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.roleMapping.noMappings', 'No mappings yet — without one, no login gets a role from the IdP.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mappingRows.map((row, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Input
|
||||||
|
placeholder={t('settings.sso.roleMapping.idpValuePlaceholder', 'IdP role or group, e.g. picpeak-admins')}
|
||||||
|
value={row.idpRole}
|
||||||
|
onChange={(e) => setRow(index, { idpRole: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={row.role}
|
||||||
|
onChange={(e) => setRow(index, { role: e.target.value })}
|
||||||
|
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
{Object.entries(ROLE_OPTIONS).map(([role, label]) => (
|
||||||
|
<option key={role} value={role}>{t(`users.roles.${role}`, label)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMappingRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev))}
|
||||||
|
className="flex-shrink-0 rounded-md p-2 text-neutral-400 hover:text-red-600 transition-colors"
|
||||||
|
aria-label={t('common.delete', 'Delete')}
|
||||||
|
title={t('common.delete', 'Delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Plus className="w-4 h-4" />}
|
||||||
|
onClick={() => setMappingRows((prev) => [...(prev || []), { idpRole: '', role: 'viewer' }])}
|
||||||
|
>
|
||||||
|
{t('settings.sso.roleMapping.addMapping', 'Add mapping')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 pt-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||||
|
checked={form.oidc_require_mapped_role}
|
||||||
|
onChange={(e) => set('oidc_require_mapped_role', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.sso.roleMapping.requireRole', 'Require a mapped role to sign in')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.roleMapping.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.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Login policy (#798 phase 2) */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.sso.policy.title', 'Login policy')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 pt-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
|
||||||
|
checked={form.oidc_disable_local_login}
|
||||||
|
onChange={(e) => set('oidc_disable_local_login', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||||
|
{t('settings.sso.policy.disableLocalLogin', 'Disable local password login')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.policy.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.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.oidc_disable_local_login && (
|
||||||
|
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
{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.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2119,7 +2119,27 @@
|
|||||||
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
|
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
|
||||||
"testFailed": "Discovery fehlgeschlagen",
|
"testFailed": "Discovery fehlgeschlagen",
|
||||||
"saved": "SSO-Einstellungen gespeichert",
|
"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": {
|
"branding": {
|
||||||
@@ -3749,13 +3769,15 @@
|
|||||||
},
|
},
|
||||||
"ssoDivider": "oder",
|
"ssoDivider": "oder",
|
||||||
"ssoSignIn": "Mit SSO anmelden",
|
"ssoSignIn": "Mit SSO anmelden",
|
||||||
|
"ssoOnlyHint": "Die Passwort-Anmeldung ist auf dieser Instanz deaktiviert — melden Sie sich über Ihren Identity Provider an.",
|
||||||
"ssoErrors": {
|
"ssoErrors": {
|
||||||
"config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.",
|
"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.",
|
"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.",
|
"idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.",
|
||||||
"inactive": "Ihr Admin-Konto ist deaktiviert.",
|
"inactive": "Ihr Admin-Konto ist deaktiviert.",
|
||||||
"not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.",
|
"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": {
|
"cssTemplates": {
|
||||||
|
|||||||
@@ -1666,7 +1666,27 @@
|
|||||||
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
|
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
|
||||||
"testFailed": "Discovery failed",
|
"testFailed": "Discovery failed",
|
||||||
"saved": "SSO settings saved",
|
"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": {
|
"analytics": {
|
||||||
@@ -3635,13 +3655,15 @@
|
|||||||
},
|
},
|
||||||
"ssoDivider": "or",
|
"ssoDivider": "or",
|
||||||
"ssoSignIn": "Sign in with SSO",
|
"ssoSignIn": "Sign in with SSO",
|
||||||
|
"ssoOnlyHint": "Password login is disabled on this instance — sign in through your identity provider.",
|
||||||
"ssoErrors": {
|
"ssoErrors": {
|
||||||
"config": "SSO is misconfigured — check the SSO settings or sign in with email and password.",
|
"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.",
|
"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.",
|
"idp": "The identity provider rejected the sign-in. Please try again or use email and password.",
|
||||||
"inactive": "Your admin account is deactivated.",
|
"inactive": "Your admin account is deactivated.",
|
||||||
"not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.",
|
"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": {
|
"slideshow": {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ssoError = searchParams.get('sso_error');
|
const ssoError = searchParams.get('sso_error');
|
||||||
if (!ssoError) return;
|
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';
|
const key = known.includes(ssoError) ? ssoError : 'idp';
|
||||||
toast.error(t(`adminLogin.ssoErrors.${key}`));
|
toast.error(t(`adminLogin.ssoErrors.${key}`));
|
||||||
}, [searchParams, t]);
|
}, [searchParams, t]);
|
||||||
@@ -262,7 +262,26 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
{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.
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm text-center text-neutral-600">
|
||||||
|
{t('adminLogin.ssoOnlyHint', 'Password login is disabled on this instance — sign in through your identity provider.')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<KeyRound className="w-4 h-4" />}
|
||||||
|
onClick={() => { window.location.href = buildResourceUrl('/api/auth/admin/sso/login'); }}
|
||||||
|
>
|
||||||
|
{settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : step === 'credentials' ? (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Form Error */}
|
{/* Form Error */}
|
||||||
{errors.form && (
|
{errors.form && (
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export interface PublicSettings {
|
|||||||
// OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login.
|
// OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login.
|
||||||
oidc_enabled?: boolean;
|
oidc_enabled?: boolean;
|
||||||
oidc_button_label?: string;
|
oidc_button_label?: string;
|
||||||
|
/** Effective flag (phase 2): the API refuses password logins right now. */
|
||||||
|
oidc_local_login_disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publicSettingsService = {
|
export const publicSettingsService = {
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export interface SsoSettings {
|
|||||||
oidc_default_role: string;
|
oidc_default_role: string;
|
||||||
oidc_button_label: string;
|
oidc_button_label: string;
|
||||||
oidc_scopes: string;
|
oidc_scopes: string;
|
||||||
|
oidc_role_mapping_enabled: boolean;
|
||||||
|
oidc_roles_claim: string;
|
||||||
|
/** IdP role/group value → PicPeak role name. */
|
||||||
|
oidc_role_mappings: Record<string, string>;
|
||||||
|
oidc_require_mapped_role: boolean;
|
||||||
|
oidc_disable_local_login: boolean;
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +34,11 @@ export interface UpdateSsoSettings {
|
|||||||
oidc_default_role?: string;
|
oidc_default_role?: string;
|
||||||
oidc_button_label?: string;
|
oidc_button_label?: string;
|
||||||
oidc_scopes?: string;
|
oidc_scopes?: string;
|
||||||
|
oidc_role_mapping_enabled?: boolean;
|
||||||
|
oidc_roles_claim?: string;
|
||||||
|
oidc_role_mappings?: Record<string, string>;
|
||||||
|
oidc_require_mapped_role?: boolean;
|
||||||
|
oidc_disable_local_login?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SsoTestResult {
|
export interface SsoTestResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user