feat(auth): OIDC SSO for admin users — phase 1 (#798)
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Minimal in-process OIDC provider for integration tests (#798).
|
||||||
|
*
|
||||||
|
* Serves just enough of the spec for openid-client's full validation to
|
||||||
|
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
|
||||||
|
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
|
||||||
|
* the next login are scripted per test via `setNextUser()`.
|
||||||
|
*
|
||||||
|
* Runs on an ephemeral localhost port over plain http — the service allows
|
||||||
|
* that in NODE_ENV=test only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { URL } = require('url');
|
||||||
|
|
||||||
|
function b64url(input) {
|
||||||
|
return Buffer.from(input).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockOidcProvider {
|
||||||
|
constructor() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
this.publicJwk = publicKey.export({ format: 'jwk' });
|
||||||
|
this.publicJwk.kid = 'test-key-1';
|
||||||
|
this.publicJwk.alg = 'RS256';
|
||||||
|
this.publicJwk.use = 'sig';
|
||||||
|
|
||||||
|
this.clientId = 'picpeak-test';
|
||||||
|
this.clientSecret = 'test-client-secret';
|
||||||
|
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
|
||||||
|
this.nextUser = { sub: 'user-1', email: '[email protected]', email_verified: true };
|
||||||
|
// Test hooks:
|
||||||
|
this.tamperNonce = false; // sign the ID token with a WRONG nonce
|
||||||
|
this.server = null;
|
||||||
|
this.issuer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextUser(user) {
|
||||||
|
this.nextUser = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
signIdToken({ sub, nonce, extraClaims = {} }) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = { alg: 'RS256', kid: this.publicJwk.kid, typ: 'JWT' };
|
||||||
|
const payload = {
|
||||||
|
iss: this.issuer,
|
||||||
|
aud: this.clientId,
|
||||||
|
sub,
|
||||||
|
iat: now,
|
||||||
|
exp: now + 300,
|
||||||
|
nonce,
|
||||||
|
...extraClaims,
|
||||||
|
};
|
||||||
|
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
|
||||||
|
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), this.privateKey);
|
||||||
|
return `${signingInput}.${signature.toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.server = http.createServer((req, res) => this.handle(req, res));
|
||||||
|
await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve));
|
||||||
|
this.issuer = `http://127.0.0.1:${this.server.address().port}`;
|
||||||
|
return this.issuer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (this.server) await new Promise((resolve) => this.server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(req, res) {
|
||||||
|
const url = new URL(req.url, this.issuer);
|
||||||
|
const json = (status, body) => {
|
||||||
|
res.writeHead(status, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (url.pathname === '/.well-known/openid-configuration') {
|
||||||
|
return json(200, {
|
||||||
|
issuer: this.issuer,
|
||||||
|
authorization_endpoint: `${this.issuer}/authorize`,
|
||||||
|
token_endpoint: `${this.issuer}/token`,
|
||||||
|
jwks_uri: `${this.issuer}/jwks`,
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
code_challenge_methods_supported: ['S256'],
|
||||||
|
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/jwks') {
|
||||||
|
return json(200, { keys: [this.publicJwk] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/authorize') {
|
||||||
|
// "Log in" instantly: mint a code bound to this request's params and
|
||||||
|
// bounce back to the redirect_uri like a real IdP would.
|
||||||
|
const code = crypto.randomBytes(16).toString('base64url');
|
||||||
|
this.codes.set(code, {
|
||||||
|
nonce: url.searchParams.get('nonce'),
|
||||||
|
redirectUri: url.searchParams.get('redirect_uri'),
|
||||||
|
codeChallenge: url.searchParams.get('code_challenge'),
|
||||||
|
user: this.nextUser,
|
||||||
|
});
|
||||||
|
const back = new URL(url.searchParams.get('redirect_uri'));
|
||||||
|
back.searchParams.set('code', code);
|
||||||
|
back.searchParams.set('state', url.searchParams.get('state'));
|
||||||
|
res.writeHead(302, { location: back.href });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/token' && req.method === 'POST') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
const stored = this.codes.get(params.get('code'));
|
||||||
|
if (!stored) return json(400, { error: 'invalid_grant' });
|
||||||
|
this.codes.delete(params.get('code'));
|
||||||
|
|
||||||
|
// PKCE check — S256(code_verifier) must match the challenge.
|
||||||
|
const verifier = params.get('code_verifier') || '';
|
||||||
|
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||||
|
if (challenge !== stored.codeChallenge) {
|
||||||
|
return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sub, ...extraClaims } = stored.user;
|
||||||
|
const idToken = this.signIdToken({
|
||||||
|
sub,
|
||||||
|
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
||||||
|
extraClaims,
|
||||||
|
});
|
||||||
|
return json(200, {
|
||||||
|
access_token: crypto.randomBytes(16).toString('base64url'),
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 300,
|
||||||
|
id_token: idToken,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(404, { error: 'not_found' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { MockOidcProvider };
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* OIDC SSO integration tests (#798, phase 1).
|
||||||
|
*
|
||||||
|
* Full-stack over a mock in-process IdP (mockOidcProvider): supertest drives
|
||||||
|
* the real /admin/sso/login and /admin/sso/callback routes on a fresh-SQLite
|
||||||
|
* database, openid-client does genuine discovery/JWKS/PKCE/ID-token
|
||||||
|
* validation against the mock issuer. Pins:
|
||||||
|
*
|
||||||
|
* - happy path: JIT provisioning creates an admin and sets the session cookie
|
||||||
|
* - JIT off → not_provisioned redirect, no row created
|
||||||
|
* - repeat login matches by sub, not email (email change ≠ new account)
|
||||||
|
* - verified-email one-time link onto an existing local admin
|
||||||
|
* - unverified email must NOT link (falls through to JIT/or error)
|
||||||
|
* - deactivated admin → inactive redirect
|
||||||
|
* - missing/forged state cookie → state redirect
|
||||||
|
* - nonce tamper from the IdP → idp redirect
|
||||||
|
* - settings endpoints: secret write-only, generic /general upsert cannot
|
||||||
|
* clobber oidc_client_secret
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 SSO (#798)', () => {
|
||||||
|
let db;
|
||||||
|
let cleanup;
|
||||||
|
let app;
|
||||||
|
let idp;
|
||||||
|
let oidcService;
|
||||||
|
|
||||||
|
const agentCookies = {};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-test-secret';
|
||||||
|
({ db, cleanup } = await bootCrmDb());
|
||||||
|
|
||||||
|
idp = new MockOidcProvider();
|
||||||
|
const issuer = await idp.start();
|
||||||
|
|
||||||
|
// Require AFTER bootCrmDb so services share this db instance.
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authRouter = require('../../src/routes/auth');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (idp) await idp.stop();
|
||||||
|
if (cleanup) await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Drive login → IdP → callback like a browser; returns the callback response. */
|
||||||
|
async function ssoRoundTrip({ mutateState } = {}) {
|
||||||
|
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||||
|
const idpUrl = loginRes.headers.location;
|
||||||
|
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
|
||||||
|
|
||||||
|
let stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||||
|
.find((c) => c.startsWith('oidc_state='));
|
||||||
|
expect(stateCookie).toBeTruthy();
|
||||||
|
stateCookie = stateCookie.split(';')[0];
|
||||||
|
if (mutateState === 'drop') stateCookie = null;
|
||||||
|
if (mutateState === 'forge') {
|
||||||
|
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Browser" follows the redirect to the IdP, which instantly bounces back.
|
||||||
|
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
|
||||||
|
expect(idpRes.status).toBe(302);
|
||||||
|
const back = new URL(idpRes.headers.get('location'));
|
||||||
|
|
||||||
|
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
|
||||||
|
if (stateCookie) cb = cb.set('Cookie', stateCookie);
|
||||||
|
return cb.expect(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('JIT-provisions an unknown user and establishes an admin session', async () => {
|
||||||
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
|
||||||
|
expect(res.headers.location).toBe('/admin/dashboard');
|
||||||
|
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
||||||
|
expect(adminCookie).toBeTruthy();
|
||||||
|
|
||||||
|
const row = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||||
|
expect(row).toBeTruthy();
|
||||||
|
expect(row.auth_provider).toBe('oidc');
|
||||||
|
expect(row.external_subject).toBe('sub-jit-1');
|
||||||
|
|
||||||
|
const role = await db('roles').where('id', row.role_id).first();
|
||||||
|
expect(role.name).toBe('viewer');
|
||||||
|
|
||||||
|
// The session JWT must be a normal admin token.
|
||||||
|
const token = adminCookie.split(';')[0].replace('admin_token=', '');
|
||||||
|
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
expect(decoded.type).toBe('admin');
|
||||||
|
expect(decoded.id).toBe(row.id);
|
||||||
|
agentCookies.jitAdminId = row.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
|
||||||
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toBe('/admin/dashboard');
|
||||||
|
|
||||||
|
// No second row — resolved via external_subject.
|
||||||
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
|
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||||
|
expect(byId.external_subject).toBe('sub-jit-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
|
||||||
|
const role = await db('roles').where({ name: 'admin' }).first();
|
||||||
|
const [localId] = await db('admin_users').insert({
|
||||||
|
username: 'local-admin',
|
||||||
|
email: '[email protected]',
|
||||||
|
password_hash: await bcrypt.hash('LocalPass123', 4),
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: 1,
|
||||||
|
auth_provider: 'local',
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||||
|
|
||||||
|
idp.setNextUser({ sub: 'sub-local-1', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toBe('/admin/dashboard');
|
||||||
|
|
||||||
|
const row = await db('admin_users').where({ id: localId }).first();
|
||||||
|
expect(row.external_subject).toBe('sub-local-1');
|
||||||
|
expect(row.auth_provider).toBe('local'); // password keeps working
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT link by unverified email — provisions a separate account instead', async () => {
|
||||||
|
const role = await db('roles').where({ name: 'admin' }).first();
|
||||||
|
await db('admin_users').insert({
|
||||||
|
username: 'victim-admin',
|
||||||
|
email: '[email protected]',
|
||||||
|
password_hash: await bcrypt.hash('VictimPass123', 4),
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: 1,
|
||||||
|
auth_provider: 'local',
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
idp.setNextUser({ sub: 'sub-attacker', email: '[email protected]', email_verified: false });
|
||||||
|
// JIT would need this email but the victim row owns it (unique) — the
|
||||||
|
// insert fails and the flow must land on an error, never on the
|
||||||
|
// victim's session.
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toMatch(/sso_error=/);
|
||||||
|
|
||||||
|
const victim = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||||
|
expect(victim.external_subject).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a deactivated admin with sso_error=inactive', async () => {
|
||||||
|
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
||||||
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toBe('/admin/login?sso_error=inactive');
|
||||||
|
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a callback without the state cookie', async () => {
|
||||||
|
const res = await ssoRoundTrip({ mutateState: 'drop' });
|
||||||
|
expect(res.headers.location).toBe('/admin/login?sso_error=state');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a forged state cookie (wrong signing key)', async () => {
|
||||||
|
const res = await ssoRoundTrip({ mutateState: 'forge' });
|
||||||
|
expect(res.headers.location).toBe('/admin/login?sso_error=state');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an ID token whose nonce does not match', async () => {
|
||||||
|
idp.tamperNonce = true;
|
||||||
|
idp.setNextUser({ sub: 'sub-nonce', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
idp.tamperNonce = false;
|
||||||
|
expect(res.headers.location).toBe('/admin/login?sso_error=idp');
|
||||||
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
|
||||||
|
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
||||||
|
idp.setNextUser({ sub: 'sub-new-user', email: '[email protected]', email_verified: true });
|
||||||
|
const res = await ssoRoundTrip();
|
||||||
|
expect(res.headers.location).toBe('/admin/login?sso_error=not_provisioned');
|
||||||
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
|
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the client secret encrypted and survives a config round-trip', async () => {
|
||||||
|
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
|
||||||
|
const stored = JSON.parse(row.setting_value);
|
||||||
|
expect(stored).not.toContain(idp.clientSecret);
|
||||||
|
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
|
||||||
|
|
||||||
|
const cfg = await oidcService.getOidcConfig();
|
||||||
|
expect(cfg.clientSecret).toBe(idp.clientSecret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 from /sso/login when SSO is disabled', async () => {
|
||||||
|
await oidcService.saveOidcSettings({ oidc_enabled: false });
|
||||||
|
await request(app).get('/api/auth/admin/sso/login').expect(404);
|
||||||
|
await oidcService.saveOidcSettings({ oidc_enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Migration 162: OIDC identity binding for admin users (#798).
|
||||||
|
*
|
||||||
|
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
|
||||||
|
* account's credentials.
|
||||||
|
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
|
||||||
|
* SSO logins match on (auth_provider, external_subject),
|
||||||
|
* NEVER on email alone — email-matching is an
|
||||||
|
* account-takeover vector with IdPs that don't verify
|
||||||
|
* addresses. Nullable: local accounts have none.
|
||||||
|
*
|
||||||
|
* Composite unique index so one IdP subject can't map to two admin rows.
|
||||||
|
* Additive + guarded; existing rows keep working untouched ('local', NULL).
|
||||||
|
*/
|
||||||
|
exports.up = async function up(knex) {
|
||||||
|
if (!(await knex.schema.hasColumn('admin_users', 'auth_provider'))) {
|
||||||
|
await knex.schema.alterTable('admin_users', (t) => {
|
||||||
|
t.string('auth_provider', 20).notNullable().defaultTo('local');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
|
||||||
|
await knex.schema.alterTable('admin_users', (t) => {
|
||||||
|
t.string('external_subject', 255).nullable();
|
||||||
|
t.unique(['auth_provider', 'external_subject'], {
|
||||||
|
indexName: 'admin_users_provider_subject_unique',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function down(knex) {
|
||||||
|
for (const col of ['external_subject', 'auth_provider']) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
if (await knex.schema.hasColumn('admin_users', col)) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await knex.schema.alterTable('admin_users', (t) => t.dropColumn(col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+63
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.80.0-beta.0",
|
"version": "3.88.0-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.80.0-beta.0",
|
"version": "3.88.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
@@ -7905,6 +7906,15 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "4.15.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||||
|
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jpeg-exif": {
|
"node_modules/jpeg-exif": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||||
@@ -9330,6 +9340,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -9342,6 +9361,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/oidc-token-hash": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
@@ -9404,6 +9432,39 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/openid-client": {
|
||||||
|
"version": "5.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
|
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jose": "^4.15.9",
|
||||||
|
"lru-cache": "^6.0.0",
|
||||||
|
"object-hash": "^2.2.0",
|
||||||
|
"oidc-token-hash": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openid-client/node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
|
"openid-client": "^5.7.1",
|
||||||
"otplib": "^12.0.1",
|
"otplib": "^12.0.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfkit": "^0.17.2",
|
"pdfkit": "^0.17.2",
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
|||||||
// (#800; writing false would reopen system-event-type deletion) and
|
// (#800; writing false would reopen system-event-type deletion) and
|
||||||
// setup_token is the first-run bootstrap secret. Every handler that loops
|
// setup_token is the first-run bootstrap secret. Every handler that loops
|
||||||
// arbitrary request keys into app_settings must strip these first.
|
// arbitrary request keys into app_settings must strip these first.
|
||||||
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
|
// 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 stripReservedSettingKeys = (settings) => {
|
const stripReservedSettingKeys = (settings) => {
|
||||||
for (const key of RESERVED_SETTING_KEYS) {
|
for (const key of RESERVED_SETTING_KEYS) {
|
||||||
delete settings[key];
|
delete settings[key];
|
||||||
@@ -376,6 +379,110 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get settings by type
|
// Get settings by type
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// OIDC SSO settings (#798). Dedicated endpoints — NOT the generic upsert —
|
||||||
|
// because the client secret must be encrypted at rest and never echoed back.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Read the SSO config. The secret is redacted to a set/unset flag; the
|
||||||
|
// computed redirect URI is included for copy-paste into the IdP client.
|
||||||
|
router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oidcService = require('../services/oidcService');
|
||||||
|
const cfg = await oidcService.getOidcConfig();
|
||||||
|
res.json({
|
||||||
|
oidc_enabled: cfg.enabled,
|
||||||
|
oidc_issuer_url: cfg.issuerUrl || '',
|
||||||
|
oidc_client_id: cfg.clientId || '',
|
||||||
|
oidc_client_secret_set: Boolean(cfg.clientSecret),
|
||||||
|
oidc_autoprovision: cfg.autoprovision,
|
||||||
|
oidc_default_role: cfg.defaultRole,
|
||||||
|
oidc_button_label: cfg.buttonLabel || '',
|
||||||
|
oidc_scopes: cfg.scopes,
|
||||||
|
redirect_uri: await oidcService.getRedirectUri(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to read SSO settings', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to read SSO settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/sso', adminAuth, requirePermission('settings.edit'), [
|
||||||
|
body('oidc_enabled').optional().isBoolean(),
|
||||||
|
body('oidc_issuer_url').optional({ checkFalsy: true }).isURL({ protocols: ['http', 'https'], require_tld: false }),
|
||||||
|
body('oidc_client_id').optional().isString().trim(),
|
||||||
|
body('oidc_client_secret').optional().isString(),
|
||||||
|
body('oidc_autoprovision').optional().isBoolean(),
|
||||||
|
body('oidc_default_role').optional().isString().trim(),
|
||||||
|
body('oidc_button_label').optional().isString().trim().isLength({ max: 60 }),
|
||||||
|
body('oidc_scopes').optional().isString().trim(),
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
const oidcService = require('../services/oidcService');
|
||||||
|
|
||||||
|
// Enabling requires a complete config, or the login button would lead
|
||||||
|
// straight to an error page.
|
||||||
|
if (req.body.oidc_enabled === true) {
|
||||||
|
const current = await oidcService.getOidcConfig();
|
||||||
|
const issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
|
||||||
|
const clientId = req.body.oidc_client_id ?? current.clientId;
|
||||||
|
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|
||||||
|
|| Boolean(current.clientSecret);
|
||||||
|
if (!issuer || !clientId || !secretPresent) {
|
||||||
|
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured before enabling SSO' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default role must exist — a typo here would brick JIT provisioning.
|
||||||
|
if (req.body.oidc_default_role !== undefined) {
|
||||||
|
const role = await db('roles').where('name', req.body.oidc_default_role).first();
|
||||||
|
if (!role) {
|
||||||
|
return res.status(400).json({ error: `Unknown role: ${req.body.oidc_default_role}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await oidcService.saveOidcSettings(req.body);
|
||||||
|
|
||||||
|
await logActivity('sso_settings_updated',
|
||||||
|
{ changes: Object.keys(req.body).filter((k) => k !== 'oidc_client_secret') },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'SSO settings saved' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to save SSO settings', { error: error.message });
|
||||||
|
res.status(500).json({ error: 'Failed to save SSO settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Server-side discovery probe: confirms the issuer is reachable and speaks
|
||||||
|
// OIDC before the admin flips the enable toggle. Uses the SAVED config.
|
||||||
|
router.post('/sso/test', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const oidcService = require('../services/oidcService');
|
||||||
|
const cfg = await oidcService.getOidcConfig();
|
||||||
|
if (!oidcService.isConfigured(cfg)) {
|
||||||
|
return res.status(400).json({ ok: false, error: 'Issuer URL, client ID and client secret must be saved first' });
|
||||||
|
}
|
||||||
|
oidcService.invalidateDiscoveryCache();
|
||||||
|
const { issuerMetadata } = await oidcService.getClient(cfg);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
issuer: issuerMetadata.issuer,
|
||||||
|
authorization_endpoint: issuerMetadata.authorization_endpoint,
|
||||||
|
token_endpoint: issuerMetadata.token_endpoint,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('SSO discovery test failed', { error: error.message });
|
||||||
|
res.status(400).json({ ok: false, error: `Discovery failed: ${error.message}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type } = req.params;
|
const { type } = req.params;
|
||||||
|
|||||||
+119
-5
@@ -42,7 +42,7 @@ const router = express.Router();
|
|||||||
* both produce an identical session. `lockoutKey` is the identifier the user
|
* both produce an identical session. `lockoutKey` is the identifier the user
|
||||||
* typed (username or email) so success/failure tracking stays in one bucket.
|
* typed (username or email) so success/failure tracking stays in one bucket.
|
||||||
*/
|
*/
|
||||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey) {
|
||||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||||
|
|
||||||
// A normal login means the first-run wizard is over — the wizard never hits
|
// A normal login means the first-run wizard is over — the wizard never hits
|
||||||
@@ -75,8 +75,7 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
|
|||||||
|
|
||||||
setAdminAuthCookie(res, token);
|
setAdminAuthCookie(res, token);
|
||||||
|
|
||||||
return res.json({
|
return {
|
||||||
user: {
|
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
email: admin.email,
|
email: admin.email,
|
||||||
@@ -85,8 +84,12 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
|
|||||||
name: admin.role_name,
|
name: admin.role_name,
|
||||||
displayName: admin.role_display_name
|
displayName: admin.role_display_name
|
||||||
} : null
|
} : null
|
||||||
}
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||||
|
const user = await establishAdminSession(res, admin, ipAddress, userAgent, lockoutKey);
|
||||||
|
return res.json({ user });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin login with enhanced security
|
// Admin login with enhanced security
|
||||||
@@ -848,4 +851,115 @@ router.post('/password-strength', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
// OIDC SSO for admins (#798, phase 1)
|
||||||
|
//
|
||||||
|
// Authorization-code + PKCE. The per-request secrets (state, nonce, PKCE
|
||||||
|
// verifier) cross the IdP redirect in a short-lived signed cookie —
|
||||||
|
// httpOnly, SameSite=Lax (the IdP returns via a top-level GET, which Lax
|
||||||
|
// permits), scoped to this route prefix. Token/claim validation happens in
|
||||||
|
// oidcService via openid-client; a successful callback reuses the exact
|
||||||
|
// session establishment of the local login, so an SSO session is
|
||||||
|
// indistinguishable from a password one downstream. MFA is the IdP's job on
|
||||||
|
// this path — local TOTP guards the password flow SSO users don't take.
|
||||||
|
// ──────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const OIDC_STATE_COOKIE = 'oidc_state';
|
||||||
|
|
||||||
|
function oidcStateCookieOptions(req) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: Boolean(req.secure),
|
||||||
|
sameSite: 'Lax',
|
||||||
|
path: '/api/auth/admin/sso',
|
||||||
|
maxAge: 10 * 60 * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick off the IdP round-trip. 404 when SSO is off so the endpoint is
|
||||||
|
// invisible on non-SSO installs.
|
||||||
|
router.get('/admin/sso/login', async (req, res) => {
|
||||||
|
const oidcService = require('../services/oidcService');
|
||||||
|
try {
|
||||||
|
const { url, state, nonce, codeVerifier } = await oidcService.buildAuthorizationRequest();
|
||||||
|
const stash = jwt.sign(
|
||||||
|
{ type: 'oidc_state', s: state, n: nonce, cv: codeVerifier },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: '10m', issuer: 'picpeak-auth' }
|
||||||
|
);
|
||||||
|
res.cookie(OIDC_STATE_COOKIE, stash, oidcStateCookieOptions(req));
|
||||||
|
return res.redirect(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'OIDC_NOT_CONFIGURED') {
|
||||||
|
return res.status(404).json({ error: 'SSO is not enabled' });
|
||||||
|
}
|
||||||
|
logger.error('OIDC login initiation failed', { error: error.message });
|
||||||
|
return res.redirect('/admin/login?sso_error=config');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// IdP redirect target. Every failure lands back on the login page with a
|
||||||
|
// translatable error key — never a raw error, never a broken JSON screen.
|
||||||
|
router.get('/admin/sso/callback', async (req, res) => {
|
||||||
|
const oidcService = require('../services/oidcService');
|
||||||
|
const fail = (key) => res.redirect(`/admin/login?sso_error=${key}`);
|
||||||
|
|
||||||
|
const stashCookie = req.cookies?.[OIDC_STATE_COOKIE];
|
||||||
|
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
|
||||||
|
if (!stashCookie) return fail('state');
|
||||||
|
|
||||||
|
let stash;
|
||||||
|
try {
|
||||||
|
stash = jwt.verify(stashCookie, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||||
|
if (stash.type !== 'oidc_state') throw new Error('wrong token type');
|
||||||
|
} catch (_) {
|
||||||
|
return fail('state');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Reconstruct the exact redirect URI + the IdP's query for validation.
|
||||||
|
const callbackUrl = new URL(await oidcService.getRedirectUri());
|
||||||
|
callbackUrl.search = req.originalUrl.split('?')[1] || '';
|
||||||
|
|
||||||
|
const claims = await oidcService.handleCallback(callbackUrl.href, {
|
||||||
|
state: stash.s,
|
||||||
|
nonce: stash.n,
|
||||||
|
codeVerifier: stash.cv,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = await oidcService.resolveAdminFromClaims(claims);
|
||||||
|
|
||||||
|
// Reload with role info so the session payload matches a local login.
|
||||||
|
const admin = await db('admin_users')
|
||||||
|
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||||
|
.where('admin_users.id', resolved.id)
|
||||||
|
.select('admin_users.*', 'roles.name as role_name', 'roles.display_name as role_display_name')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const ipAddress = getClientIp(req);
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
await establishAdminSession(res, admin, ipAddress, userAgent, admin.username);
|
||||||
|
|
||||||
|
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
|
||||||
|
type: 'admin', id: admin.id, name: admin.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.redirect('/admin/dashboard');
|
||||||
|
} catch (error) {
|
||||||
|
const codeMap = {
|
||||||
|
OIDC_NOT_CONFIGURED: 'config',
|
||||||
|
OIDC_BAD_CONFIG: 'config',
|
||||||
|
OIDC_INACTIVE: 'inactive',
|
||||||
|
OIDC_NOT_PROVISIONED: 'not_provisioned',
|
||||||
|
OIDC_NO_EMAIL: 'no_email',
|
||||||
|
OIDC_BAD_CLAIMS: 'idp',
|
||||||
|
};
|
||||||
|
const key = codeMap[error.code] || 'idp';
|
||||||
|
// 'idp' covers token-exchange/validation failures from openid-client
|
||||||
|
// (bad state/nonce, signature, issuer mismatch, IdP-side errors).
|
||||||
|
logger.warn('OIDC callback failed', { error: error.message, key });
|
||||||
|
return fail(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ router.get('/', async (req, res) => {
|
|||||||
// backend route also enforces it via getMaxFilesPerUpload,
|
// backend route also enforces it via getMaxFilesPerUpload,
|
||||||
// but a client-side guard saves a 4MB+ round-trip when the
|
// but a client-side guard saves a 4MB+ round-trip when the
|
||||||
// limit is small.
|
// limit is small.
|
||||||
'general_max_files_per_upload'
|
'general_max_files_per_upload',
|
||||||
|
// #798 — the admin login page needs to know whether to show
|
||||||
|
// the "Sign in with SSO" button (and its label). Only these
|
||||||
|
// two oidc_* keys are public; issuer/client stay admin-only.
|
||||||
|
'oidc_enabled',
|
||||||
|
'oidc_button_label'
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
.select('setting_key', 'setting_value');
|
.select('setting_key', 'setting_value');
|
||||||
@@ -128,6 +133,10 @@ router.get('/', async (req, res) => {
|
|||||||
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
|
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
|
||||||
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
|
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== false,
|
||||||
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
|
crm_overview_show_invoices: settingsObject.crm_overview_show_invoices !== false,
|
||||||
|
// OIDC SSO (#798): the admin login page renders the "Sign in with
|
||||||
|
// SSO" button from these. Issuer/client/secret are never public.
|
||||||
|
oidc_enabled: settingsObject.oidc_enabled === true,
|
||||||
|
oidc_button_label: settingsObject.oidc_button_label || '',
|
||||||
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',
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* OIDC SSO for admin users (#798, phase 1).
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Identity binding: SSO logins match on `admin_users.external_subject` (the
|
||||||
|
* IdP's stable `sub` claim) — NEVER on email alone, which is an
|
||||||
|
* account-takeover vector with IdPs that don't verify addresses. A one-time
|
||||||
|
* link of an EXISTING local admin by email is allowed only when the ID token
|
||||||
|
* carries `email_verified: true`; the sub is stamped so all future logins
|
||||||
|
* match by sub even if the email changes. Linked local admins keep
|
||||||
|
* `auth_provider='local'` (their password still works); JIT-provisioned rows
|
||||||
|
* get `auth_provider='oidc'` and an unusable random password hash.
|
||||||
|
*
|
||||||
|
* Config lives in app_settings (oidc_* keys, managed via the dedicated
|
||||||
|
* /admin/settings/sso endpoints). The client secret is AES-256-GCM encrypted
|
||||||
|
* at rest — same construction as mfaService, own salt, key from
|
||||||
|
* OIDC_ENCRYPTION_KEY (fallback JWT_SECRET).
|
||||||
|
*
|
||||||
|
* MFA is delegated to the IdP for SSO logins: local TOTP protects the local
|
||||||
|
* password path, which SSO users don't take.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
// openid-client v5 (CommonJS). v6+ is ESM-only, which Node 22 can require()
|
||||||
|
// but Jest's CJS runtime cannot — v5 is the battle-tested major and its
|
||||||
|
// protocol coverage (discovery, PKCE, full ID-token validation) is identical
|
||||||
|
// for our flow.
|
||||||
|
const { Issuer, generators } = require('openid-client');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const ENC_ALGO = 'aes-256-gcm';
|
||||||
|
const ENC_SALT = 'picpeak-oidc-secret-v1'; // fixed: derivation must be stable
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AES-256-GCM encrypt → "iv.tag.ciphertext" (all base64url). */
|
||||||
|
function encryptSecret(plainSecret) {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const cipher = crypto.createCipheriv(ENC_ALGO, key, iv);
|
||||||
|
const ct = Buffer.concat([cipher.update(plainSecret, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [iv, tag, ct].map((b) => b.toString('base64url')).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reverse of encryptSecret. Throws on tamper/wrong key. */
|
||||||
|
function decryptSecret(stored) {
|
||||||
|
const key = getEncryptionKey();
|
||||||
|
const [ivB64, tagB64, ctB64] = String(stored).split('.');
|
||||||
|
if (!ivB64 || !tagB64 || !ctB64) {
|
||||||
|
throw new Error('oidcService: malformed encrypted secret');
|
||||||
|
}
|
||||||
|
const decipher = crypto.createDecipheriv(ENC_ALGO, key, Buffer.from(ivB64, 'base64url'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tagB64, 'base64url'));
|
||||||
|
const pt = Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64url')), decipher.final()]);
|
||||||
|
return pt.toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the full OIDC config from app_settings. Secret is returned DECRYPTED —
|
||||||
|
* for internal use only; the settings GET endpoint must never call this.
|
||||||
|
*/
|
||||||
|
async function getOidcConfig() {
|
||||||
|
const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes] =
|
||||||
|
await Promise.all([
|
||||||
|
getAppSetting('oidc_enabled'),
|
||||||
|
getAppSetting('oidc_issuer_url'),
|
||||||
|
getAppSetting('oidc_client_id'),
|
||||||
|
getAppSetting('oidc_client_secret'),
|
||||||
|
getAppSetting('oidc_autoprovision'),
|
||||||
|
getAppSetting('oidc_default_role'),
|
||||||
|
getAppSetting('oidc_button_label'),
|
||||||
|
getAppSetting('oidc_scopes'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let clientSecret = null;
|
||||||
|
if (encSecret) {
|
||||||
|
try {
|
||||||
|
clientSecret = decryptSecret(encSecret);
|
||||||
|
} catch (err) {
|
||||||
|
// Wrong key / tampered / plaintext-clobbered value → treat as
|
||||||
|
// unconfigured rather than sending garbage to the IdP.
|
||||||
|
logger.error('OIDC client secret could not be decrypted — treating SSO as unconfigured', {
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: enabled === true,
|
||||||
|
issuerUrl: issuerUrl || null,
|
||||||
|
clientId: clientId || null,
|
||||||
|
clientSecret,
|
||||||
|
autoprovision: autoprovision === true,
|
||||||
|
defaultRole: defaultRole || 'viewer',
|
||||||
|
buttonLabel: buttonLabel || null,
|
||||||
|
scopes: scopes || 'openid profile email',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfigured(cfg) {
|
||||||
|
return Boolean(cfg.issuerUrl && cfg.clientId && cfg.clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery result cache. Keyed by issuer+client so a settings change gets a
|
||||||
|
// fresh client; invalidated explicitly on settings save too.
|
||||||
|
let _clientCache = null; // { key, client, issuerMetadata }
|
||||||
|
|
||||||
|
function invalidateDiscoveryCache() {
|
||||||
|
_clientCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the openid-client Client for the current settings, performing
|
||||||
|
* OIDC discovery on first use. Throws on unreachable/invalid issuer —
|
||||||
|
* callers surface that as a config error.
|
||||||
|
*/
|
||||||
|
async function getClient(cfg) {
|
||||||
|
const key = `${cfg.issuerUrl}|${cfg.clientId}`;
|
||||||
|
if (_clientCache && _clientCache.key === key) {
|
||||||
|
return _clientCache;
|
||||||
|
}
|
||||||
|
const issuer = await Issuer.discover(cfg.issuerUrl);
|
||||||
|
const client = new issuer.Client({
|
||||||
|
client_id: cfg.clientId,
|
||||||
|
client_secret: cfg.clientSecret,
|
||||||
|
redirect_uris: [await getRedirectUri()],
|
||||||
|
response_types: ['code'],
|
||||||
|
});
|
||||||
|
_clientCache = { key, client, issuerMetadata: issuer.metadata };
|
||||||
|
return _clientCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redirect URI registered with the IdP. Derived from the public frontend
|
||||||
|
* base URL — nginx proxies /api to the backend, so this resolves publicly.
|
||||||
|
*/
|
||||||
|
async function getRedirectUri() {
|
||||||
|
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||||
|
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
|
||||||
|
return `${base}/api/auth/admin/sso/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the IdP authorization URL plus the per-request secrets the callback
|
||||||
|
* needs (state, nonce, PKCE verifier). The route stores those in a
|
||||||
|
* short-lived signed cookie — this service is stateless across the redirect.
|
||||||
|
*/
|
||||||
|
async function buildAuthorizationRequest() {
|
||||||
|
const cfg = await getOidcConfig();
|
||||||
|
if (!cfg.enabled || !isConfigured(cfg)) {
|
||||||
|
const err = new Error('SSO is not enabled or not fully configured');
|
||||||
|
err.code = 'OIDC_NOT_CONFIGURED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { client } = await getClient(cfg);
|
||||||
|
|
||||||
|
const codeVerifier = generators.codeVerifier();
|
||||||
|
const codeChallenge = generators.codeChallenge(codeVerifier);
|
||||||
|
const state = generators.state();
|
||||||
|
const nonce = generators.nonce();
|
||||||
|
|
||||||
|
const url = client.authorizationUrl({
|
||||||
|
redirect_uri: await getRedirectUri(),
|
||||||
|
scope: cfg.scopes,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { url, state, nonce, codeVerifier };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange the authorization code and validate the ID token (issuer,
|
||||||
|
* audience, signature, nonce, state — all enforced by openid-client).
|
||||||
|
* Returns the ID token claims.
|
||||||
|
*/
|
||||||
|
async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
||||||
|
const cfg = await getOidcConfig();
|
||||||
|
if (!cfg.enabled || !isConfigured(cfg)) {
|
||||||
|
const err = new Error('SSO is not enabled or not fully configured');
|
||||||
|
err.code = 'OIDC_NOT_CONFIGURED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const { client } = await getClient(cfg);
|
||||||
|
|
||||||
|
// Extract code/state from the callback URL, then exchange + validate the
|
||||||
|
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
|
||||||
|
// by openid-client).
|
||||||
|
const callbackUrl = new URL(currentUrl);
|
||||||
|
const params = Object.fromEntries(callbackUrl.searchParams.entries());
|
||||||
|
const tokenSet = await client.callback(await getRedirectUri(), params, {
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
return tokenSet.claims();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map validated ID token claims to an admin_users row.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. external_subject === sub → that admin (must be active).
|
||||||
|
* 2. email match against an UNLINKED admin, only if email_verified === true
|
||||||
|
* → one-time link (stamps external_subject; auth_provider unchanged so
|
||||||
|
* a local password keeps working).
|
||||||
|
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
|
||||||
|
* claim; role = oidc_default_role; unusable random password).
|
||||||
|
*
|
||||||
|
* Errors carry a `code` the route maps to a redirect error key.
|
||||||
|
*/
|
||||||
|
async function resolveAdminFromClaims(claims) {
|
||||||
|
const sub = claims.sub;
|
||||||
|
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
|
||||||
|
const emailVerified = claims.email_verified === true;
|
||||||
|
|
||||||
|
if (!sub) {
|
||||||
|
const err = new Error('ID token has no sub claim');
|
||||||
|
err.code = 'OIDC_BAD_CLAIMS';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Established binding.
|
||||||
|
const bySub = await db('admin_users').where('external_subject', sub).first();
|
||||||
|
if (bySub) {
|
||||||
|
if (!bySub.is_active) {
|
||||||
|
const err = new Error('Admin account is deactivated');
|
||||||
|
err.code = 'OIDC_INACTIVE';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return bySub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. One-time email link — verified emails only, and only onto rows that
|
||||||
|
// have no binding yet (a different sub on the row means a different
|
||||||
|
// IdP identity already owns it).
|
||||||
|
if (email && emailVerified) {
|
||||||
|
const byEmail = await db('admin_users')
|
||||||
|
.where('email', email)
|
||||||
|
.whereNull('external_subject')
|
||||||
|
.first();
|
||||||
|
if (byEmail) {
|
||||||
|
if (!byEmail.is_active) {
|
||||||
|
const err = new Error('Admin account is deactivated');
|
||||||
|
err.code = 'OIDC_INACTIVE';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await db('admin_users').where('id', byEmail.id).update({
|
||||||
|
external_subject: sub,
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
logger.info('OIDC: linked existing admin to IdP subject', {
|
||||||
|
adminId: byEmail.id,
|
||||||
|
sub,
|
||||||
|
});
|
||||||
|
return { ...byEmail, external_subject: sub };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
const err = new Error('IdP supplied no email claim — cannot provision an account');
|
||||||
|
err.code = 'OIDC_NO_EMAIL';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = 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';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unusable-but-valid bcrypt hash: local login always fails for this row,
|
||||||
|
// and nothing downstream chokes on a malformed hash.
|
||||||
|
const passwordHash = await bcrypt.hash(crypto.randomBytes(32).toString('base64url'), getBcryptRounds());
|
||||||
|
|
||||||
|
const inserted = await db('admin_users')
|
||||||
|
.insert({
|
||||||
|
username: email,
|
||||||
|
email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
must_change_password: formatBoolean(false),
|
||||||
|
auth_provider: 'oidc',
|
||||||
|
external_subject: sub,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.returning('id');
|
||||||
|
const adminId = inserted[0]?.id || inserted[0];
|
||||||
|
logger.info('OIDC: JIT-provisioned admin from IdP', { adminId, sub, role: cfg.defaultRole });
|
||||||
|
|
||||||
|
return db('admin_users').where('id', adminId).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist SSO settings (dedicated endpoint — the generic settings upserts
|
||||||
|
* strip oidc_client_secret so it can't be clobbered with plaintext).
|
||||||
|
* An absent/empty secret keeps the stored one.
|
||||||
|
*/
|
||||||
|
async function saveOidcSettings(input) {
|
||||||
|
const writes = [];
|
||||||
|
const put = (key, value, type) => writes.push(upsertAppSetting(key, JSON.stringify(value), type));
|
||||||
|
|
||||||
|
if (input.oidc_enabled !== undefined) put('oidc_enabled', input.oidc_enabled === true, 'boolean');
|
||||||
|
if (input.oidc_issuer_url !== undefined) put('oidc_issuer_url', String(input.oidc_issuer_url).trim(), 'string');
|
||||||
|
if (input.oidc_client_id !== undefined) put('oidc_client_id', String(input.oidc_client_id).trim(), 'string');
|
||||||
|
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
|
||||||
|
if (input.oidc_default_role !== undefined) put('oidc_default_role', String(input.oidc_default_role).trim(), 'string');
|
||||||
|
if (input.oidc_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
|
||||||
|
if (input.oidc_scopes !== undefined) put('oidc_scopes', String(input.oidc_scopes).trim() || 'openid profile email', 'string');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOidcConfig,
|
||||||
|
isConfigured,
|
||||||
|
getRedirectUri,
|
||||||
|
buildAuthorizationRequest,
|
||||||
|
handleCallback,
|
||||||
|
resolveAdminFromClaims,
|
||||||
|
saveOidcSettings,
|
||||||
|
invalidateDiscoveryCache,
|
||||||
|
getClient,
|
||||||
|
encryptSecret,
|
||||||
|
decryptSecret,
|
||||||
|
};
|
||||||
@@ -20,3 +20,4 @@ export { ApiTokensTab } from './tabs/ApiTokensTab';
|
|||||||
export { WebhooksTab } from './tabs/WebhooksTab';
|
export { WebhooksTab } from './tabs/WebhooksTab';
|
||||||
export { AccountingTab } from './tabs/AccountingTab';
|
export { AccountingTab } from './tabs/AccountingTab';
|
||||||
export { WhatsAppTab } from './tabs/WhatsAppTab';
|
export { WhatsAppTab } from './tabs/WhatsAppTab';
|
||||||
|
export { SsoTab } from './tabs/SsoTab';
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
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 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.
|
||||||
|
export const SsoTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [form, setForm] = useState<SsoSettings | null>(null);
|
||||||
|
const [newSecret, setNewSecret] = useState('');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const { isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-sso-settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const settings = await ssoService.getSettings();
|
||||||
|
setForm((prev) => prev ?? settings);
|
||||||
|
return settings;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdateSsoSettings) => ssoService.updateSettings(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('settings.sso.saved', 'SSO settings saved'));
|
||||||
|
setNewSecret('');
|
||||||
|
setForm(null); // re-init from the fresh GET (secret_set flag updates)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-sso-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||||
|
},
|
||||||
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(error.response?.data?.error || t('settings.sso.saveError', 'Failed to save SSO settings'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const testMutation = useMutation({
|
||||||
|
mutationFn: () => ssoService.testConnection(),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t('settings.sso.testOk', 'Discovery succeeded — issuer is reachable: {{issuer}}', { issuer: result.issuer }));
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || t('settings.sso.testFailed', 'Discovery failed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(error.response?.data?.error || t('settings.sso.testFailed', 'Discovery failed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !form) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = <K extends keyof SsoSettings>(key: K, value: SsoSettings[K]) =>
|
||||||
|
setForm((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const payload: UpdateSsoSettings = {
|
||||||
|
oidc_enabled: form.oidc_enabled,
|
||||||
|
oidc_issuer_url: form.oidc_issuer_url.trim(),
|
||||||
|
oidc_client_id: form.oidc_client_id.trim(),
|
||||||
|
oidc_autoprovision: form.oidc_autoprovision,
|
||||||
|
oidc_default_role: form.oidc_default_role,
|
||||||
|
oidc_button_label: form.oidc_button_label.trim(),
|
||||||
|
oidc_scopes: form.oidc_scopes.trim(),
|
||||||
|
};
|
||||||
|
if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim();
|
||||||
|
saveMutation.mutate(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyRedirectUri = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(form.redirect_uri);
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// Clipboard unavailable — the URI is visible to copy by hand.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.sso.title', 'Single Sign-On (OIDC)')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.intro', 'Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Redirect URI for the IdP client registration */}
|
||||||
|
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 p-3">
|
||||||
|
<p className="text-xs font-medium text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.redirectUri', 'Redirect URI (register this on your IdP client)')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<code className="flex-1 overflow-x-auto whitespace-nowrap rounded bg-neutral-900 px-3 py-2 font-mono text-xs text-neutral-100">
|
||||||
|
{form.redirect_uri}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyRedirectUri}
|
||||||
|
className="flex-shrink-0 rounded-md border border-neutral-200 dark:border-neutral-600 bg-white dark:bg-neutral-700 p-2 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 transition-colors"
|
||||||
|
aria-label={t('common.copy', 'Copy')}
|
||||||
|
title={t('common.copy', 'Copy')}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.issuerUrl', 'Issuer URL')}
|
||||||
|
placeholder="https://id.example.com/realms/main"
|
||||||
|
value={form.oidc_issuer_url}
|
||||||
|
onChange={(e) => set('oidc_issuer_url', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="-mt-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.issuerHint', 'The base URL that serves /.well-known/openid-configuration.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.clientId', 'Client ID')}
|
||||||
|
value={form.oidc_client_id}
|
||||||
|
onChange={(e) => set('oidc_client_id', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.clientSecret', 'Client Secret')}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={form.oidc_client_secret_set
|
||||||
|
? t('settings.sso.secretSetPlaceholder', '•••••• (saved — type to replace)')
|
||||||
|
: t('settings.sso.secretUnsetPlaceholder', 'Paste the client secret')}
|
||||||
|
value={newSecret}
|
||||||
|
onChange={(e) => setNewSecret(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.secretHint', 'Stored encrypted; never shown again. Leave blank to keep the current one.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.scopes', 'Scopes')}
|
||||||
|
value={form.oidc_scopes}
|
||||||
|
onChange={(e) => set('oidc_scopes', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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_autoprovision}
|
||||||
|
onChange={(e) => set('oidc_autoprovision', 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.autoprovision', 'Auto-provision unknown users')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.autoprovisionHint', 'Create an admin account on first SSO login. Off: only existing/linked admins can sign in.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{form.oidc_autoprovision && (
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
{t('settings.sso.defaultRole', 'Role for new users')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={form.oidc_default_role}
|
||||||
|
onChange={(e) => set('oidc_default_role', e.target.value)}
|
||||||
|
className="w-full 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"
|
||||||
|
>
|
||||||
|
<option value="viewer">{t('users.roles.viewer', 'Viewer')}</option>
|
||||||
|
<option value="editor">{t('users.roles.editor', 'Editor')}</option>
|
||||||
|
<option value="admin">{t('users.roles.admin', 'Admin')}</option>
|
||||||
|
<option value="super_admin">{t('users.roles.super_admin', 'Super Admin')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t('settings.sso.buttonLabel', 'Login button label (optional)')}
|
||||||
|
placeholder={t('settings.sso.buttonLabelPlaceholder', 'Sign in with SSO')}
|
||||||
|
value={form.oidc_button_label}
|
||||||
|
onChange={(e) => set('oidc_button_label', e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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_enabled}
|
||||||
|
onChange={(e) => set('oidc_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.enabled', 'Enable SSO login')}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.enabledHint', 'Shows the SSO button on the admin login page. Requires issuer, client ID and secret.')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
onClick={handleSave}
|
||||||
|
isLoading={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftIcon={<PlugZap className="w-4 h-4" />}
|
||||||
|
onClick={() => testMutation.mutate()}
|
||||||
|
isLoading={testMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('settings.sso.test', 'Test connection')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('settings.sso.testHint', 'Test runs OIDC discovery against the saved configuration — save first.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SsoTab.displayName = 'SsoTab';
|
||||||
@@ -2090,6 +2090,32 @@
|
|||||||
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
|
"enableFailed": "Zwei-Faktor-Authentifizierung konnte nicht aktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||||
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
|
"disableFailed": "Zwei-Faktor-Authentifizierung konnte nicht deaktiviert werden. Prüfe den Code und versuche es erneut.",
|
||||||
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
|
"regenerateFailed": "Wiederherstellungscodes konnten nicht neu erzeugt werden. Prüfe den Code und versuche es erneut."
|
||||||
|
},
|
||||||
|
"sso": {
|
||||||
|
"title": "Single Sign-On (OIDC)",
|
||||||
|
"intro": "Admins melden sich über Ihren Identity Provider an (Keycloak, Authentik, Pocket ID oder jeder OIDC-konforme IdP). Die lokale Anmeldung mit E-Mail/Passwort bleibt als Fallback verfügbar.",
|
||||||
|
"redirectUri": "Redirect-URI (beim IdP-Client registrieren)",
|
||||||
|
"issuerUrl": "Issuer-URL",
|
||||||
|
"issuerHint": "Die Basis-URL, die /.well-known/openid-configuration ausliefert.",
|
||||||
|
"clientId": "Client-ID",
|
||||||
|
"clientSecret": "Client-Secret",
|
||||||
|
"secretSetPlaceholder": "•••••• (gespeichert — zum Ersetzen tippen)",
|
||||||
|
"secretUnsetPlaceholder": "Client-Secret einfügen",
|
||||||
|
"secretHint": "Verschlüsselt gespeichert; wird nie wieder angezeigt. Leer lassen, um das aktuelle zu behalten.",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"autoprovision": "Unbekannte Benutzer automatisch anlegen",
|
||||||
|
"autoprovisionHint": "Erstellt beim ersten SSO-Login ein Admin-Konto. Aus: Nur bestehende/verknüpfte Admins können sich anmelden.",
|
||||||
|
"defaultRole": "Rolle für neue Benutzer",
|
||||||
|
"buttonLabel": "Beschriftung des Login-Buttons (optional)",
|
||||||
|
"buttonLabelPlaceholder": "Mit SSO anmelden",
|
||||||
|
"enabled": "SSO-Login aktivieren",
|
||||||
|
"enabledHint": "Zeigt den SSO-Button auf der Admin-Anmeldeseite. Erfordert Issuer, Client-ID und Secret.",
|
||||||
|
"test": "Verbindung testen",
|
||||||
|
"testHint": "Der Test führt OIDC-Discovery gegen die gespeicherte Konfiguration aus — zuerst speichern.",
|
||||||
|
"testOk": "Discovery erfolgreich — Issuer ist erreichbar: {{issuer}}",
|
||||||
|
"testFailed": "Discovery fehlgeschlagen",
|
||||||
|
"saved": "SSO-Einstellungen gespeichert",
|
||||||
|
"saveError": "SSO-Einstellungen konnten nicht gespeichert werden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
@@ -3706,6 +3732,16 @@
|
|||||||
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
|
"sessionExpired": "Deine Bestätigungssitzung ist abgelaufen. Bitte melde dich erneut an.",
|
||||||
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
|
"locked": "Konto wegen zu vieler Versuche vorübergehend gesperrt. Versuche es später erneut.",
|
||||||
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
|
"lockedRetry": "Konto vorübergehend gesperrt. Versuche es in {{seconds}} Sekunden erneut."
|
||||||
|
},
|
||||||
|
"ssoDivider": "oder",
|
||||||
|
"ssoSignIn": "Mit SSO anmelden",
|
||||||
|
"ssoErrors": {
|
||||||
|
"config": "SSO ist falsch konfiguriert — prüfen Sie die SSO-Einstellungen oder melden Sie sich mit E-Mail und Passwort an.",
|
||||||
|
"state": "Die SSO-Anmeldung ist abgelaufen oder wurde manipuliert. Bitte erneut versuchen.",
|
||||||
|
"idp": "Der Identity Provider hat die Anmeldung abgelehnt. Bitte erneut versuchen oder E-Mail und Passwort verwenden.",
|
||||||
|
"inactive": "Ihr Admin-Konto ist deaktiviert.",
|
||||||
|
"not_provisioned": "Kein Admin-Konto passt zu Ihrer SSO-Identität. Bitten Sie einen Administrator um eine Einladung.",
|
||||||
|
"no_email": "Ihr Identity Provider hat keine E-Mail-Adresse geliefert — es kann kein Konto erstellt werden."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cssTemplates": {
|
"cssTemplates": {
|
||||||
|
|||||||
@@ -1637,6 +1637,32 @@
|
|||||||
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
|
"enableFailed": "Could not enable two-factor authentication. Check the code and try again.",
|
||||||
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
|
"disableFailed": "Could not disable two-factor authentication. Check the code and try again.",
|
||||||
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
|
"regenerateFailed": "Could not regenerate recovery codes. Check the code and try again."
|
||||||
|
},
|
||||||
|
"sso": {
|
||||||
|
"title": "Single Sign-On (OIDC)",
|
||||||
|
"intro": "Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.",
|
||||||
|
"redirectUri": "Redirect URI (register this on your IdP client)",
|
||||||
|
"issuerUrl": "Issuer URL",
|
||||||
|
"issuerHint": "The base URL that serves /.well-known/openid-configuration.",
|
||||||
|
"clientId": "Client ID",
|
||||||
|
"clientSecret": "Client Secret",
|
||||||
|
"secretSetPlaceholder": "•••••• (saved — type to replace)",
|
||||||
|
"secretUnsetPlaceholder": "Paste the client secret",
|
||||||
|
"secretHint": "Stored encrypted; never shown again. Leave blank to keep the current one.",
|
||||||
|
"scopes": "Scopes",
|
||||||
|
"autoprovision": "Auto-provision unknown users",
|
||||||
|
"autoprovisionHint": "Create an admin account on first SSO login. Off: only existing/linked admins can sign in.",
|
||||||
|
"defaultRole": "Role for new users",
|
||||||
|
"buttonLabel": "Login button label (optional)",
|
||||||
|
"buttonLabelPlaceholder": "Sign in with SSO",
|
||||||
|
"enabled": "Enable SSO login",
|
||||||
|
"enabledHint": "Shows the SSO button on the admin login page. Requires issuer, client ID and secret.",
|
||||||
|
"test": "Test connection",
|
||||||
|
"testHint": "Test runs OIDC discovery against the saved configuration — save first.",
|
||||||
|
"testOk": "Discovery succeeded — issuer is reachable: {{issuer}}",
|
||||||
|
"testFailed": "Discovery failed",
|
||||||
|
"saved": "SSO settings saved",
|
||||||
|
"saveError": "Failed to save SSO settings"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
@@ -3595,6 +3621,16 @@
|
|||||||
"sessionExpired": "Your verification session expired. Please sign in again.",
|
"sessionExpired": "Your verification session expired. Please sign in again.",
|
||||||
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
||||||
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
|
"lockedRetry": "Account temporarily locked. Try again in {{seconds}} seconds."
|
||||||
|
},
|
||||||
|
"ssoDivider": "or",
|
||||||
|
"ssoSignIn": "Sign in with SSO",
|
||||||
|
"ssoErrors": {
|
||||||
|
"config": "SSO is misconfigured — check the SSO settings or sign in with email and password.",
|
||||||
|
"state": "The SSO sign-in expired or was tampered with. Please try again.",
|
||||||
|
"idp": "The identity provider rejected the sign-in. Please try again or use email and password.",
|
||||||
|
"inactive": "Your admin account is deactivated.",
|
||||||
|
"not_provisioned": "No admin account matches your SSO identity. Ask an administrator to invite you.",
|
||||||
|
"no_email": "Your identity provider supplied no email address — an account cannot be created."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"slideshow": {
|
"slideshow": {
|
||||||
|
|||||||
@@ -61,6 +61,16 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [searchParams, t]);
|
}, [searchParams, t]);
|
||||||
|
|
||||||
|
// SSO callback failures land here as ?sso_error=<key> (#798) — surface a
|
||||||
|
// translated message instead of a silent bounce back to the form.
|
||||||
|
useEffect(() => {
|
||||||
|
const ssoError = searchParams.get('sso_error');
|
||||||
|
if (!ssoError) return;
|
||||||
|
const known = ['config', 'state', 'idp', 'inactive', 'not_provisioned', 'no_email'];
|
||||||
|
const key = known.includes(ssoError) ? ssoError : 'idp';
|
||||||
|
toast.error(t(`adminLogin.ssoErrors.${key}`));
|
||||||
|
}, [searchParams, t]);
|
||||||
|
|
||||||
// Fresh instance with no admin yet → send to first-run setup.
|
// Fresh instance with no admin yet → send to first-run setup.
|
||||||
const { data: setupStatus } = useQuery({
|
const { data: setupStatus } = useQuery({
|
||||||
queryKey: ['setup-status'],
|
queryKey: ['setup-status'],
|
||||||
@@ -337,6 +347,31 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('adminLogin.signIn')}
|
{t('adminLogin.signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* SSO (#798): plain navigation — the backend route redirects to
|
||||||
|
the IdP; the callback sets the same admin cookie as the local
|
||||||
|
login and lands on the dashboard. */}
|
||||||
|
{settingsData?.oidc_enabled === true && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 border-t border-neutral-200" />
|
||||||
|
<span className="text-xs uppercase tracking-wide text-neutral-400">
|
||||||
|
{t('adminLogin.ssoDivider', 'or')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 border-t border-neutral-200" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
leftIcon={<KeyRound className="w-4 h-4" />}
|
||||||
|
onClick={() => { window.location.href = '/api/auth/admin/sso/login'; }}
|
||||||
|
>
|
||||||
|
{settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleMfaSubmit} className="space-y-6">
|
<form onSubmit={handleMfaSubmit} className="space-y-6">
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
WebhooksTab,
|
WebhooksTab,
|
||||||
AccountingTab,
|
AccountingTab,
|
||||||
WhatsAppTab,
|
WhatsAppTab,
|
||||||
|
SsoTab,
|
||||||
} from '../../features/settings';
|
} from '../../features/settings';
|
||||||
import { EmailConfigPage } from './EmailConfigPage';
|
import { EmailConfigPage } from './EmailConfigPage';
|
||||||
import { BrandingPage } from './BrandingPage';
|
import { BrandingPage } from './BrandingPage';
|
||||||
@@ -72,6 +73,7 @@ type TabType =
|
|||||||
| 'email'
|
| 'email'
|
||||||
| 'moderation'
|
| 'moderation'
|
||||||
| 'security'
|
| 'security'
|
||||||
|
| 'sso'
|
||||||
| 'imageSecurity'
|
| 'imageSecurity'
|
||||||
| 'seo'
|
| 'seo'
|
||||||
| 'apiTokens'
|
| 'apiTokens'
|
||||||
@@ -104,7 +106,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
|||||||
'features', 'general', 'events', 'eventTypes',
|
'features', 'general', 'events', 'eventTypes',
|
||||||
'branding', 'categories', 'thumbnails', 'styling', 'cms',
|
'branding', 'categories', 'thumbnails', 'styling', 'cms',
|
||||||
'email', 'moderation',
|
'email', 'moderation',
|
||||||
'security', 'imageSecurity', 'seo',
|
'security', 'sso', 'imageSecurity', 'seo',
|
||||||
'apiTokens', 'webhooks',
|
'apiTokens', 'webhooks',
|
||||||
'status', 'analytics', 'backup',
|
'status', 'analytics', 'backup',
|
||||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
|
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
|
||||||
@@ -260,6 +262,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
|
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
|
||||||
items: [
|
items: [
|
||||||
{ key: 'security', label: t('settings.security.title'), icon: Lock },
|
{ key: 'security', label: t('settings.security.title'), icon: Lock },
|
||||||
|
{ key: 'sso', label: t('settings.sso.title', 'Single Sign-On'), icon: KeyRound },
|
||||||
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield },
|
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection'), icon: Shield },
|
||||||
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
|
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
|
||||||
],
|
],
|
||||||
@@ -472,6 +475,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'sso' && <SsoTab />}
|
||||||
|
|
||||||
{activeTab === 'security' && (
|
{activeTab === 'security' && (
|
||||||
<SecurityTab
|
<SecurityTab
|
||||||
securitySettings={securitySettings}
|
securitySettings={securitySettings}
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export interface PublicSettings {
|
|||||||
seo_meta_noindex?: boolean;
|
seo_meta_noindex?: boolean;
|
||||||
seo_meta_nofollow?: boolean;
|
seo_meta_nofollow?: boolean;
|
||||||
seo_meta_noai?: boolean;
|
seo_meta_noai?: boolean;
|
||||||
|
// OIDC SSO (#798) — drives the "Sign in with SSO" button on /admin/login.
|
||||||
|
oidc_enabled?: boolean;
|
||||||
|
oidc_button_label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const publicSettingsService = {
|
export const publicSettingsService = {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* SSO (OIDC) Settings Service (#798)
|
||||||
|
* API client for the dedicated /admin/settings/sso endpoints — the client
|
||||||
|
* secret is write-only (never returned; `oidc_client_secret_set` flags it).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface SsoSettings {
|
||||||
|
oidc_enabled: boolean;
|
||||||
|
oidc_issuer_url: string;
|
||||||
|
oidc_client_id: string;
|
||||||
|
oidc_client_secret_set: boolean;
|
||||||
|
oidc_autoprovision: boolean;
|
||||||
|
oidc_default_role: string;
|
||||||
|
oidc_button_label: string;
|
||||||
|
oidc_scopes: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSsoSettings {
|
||||||
|
oidc_enabled?: boolean;
|
||||||
|
oidc_issuer_url?: string;
|
||||||
|
oidc_client_id?: string;
|
||||||
|
/** Only sent when the admin typed a new one; empty keeps the stored secret. */
|
||||||
|
oidc_client_secret?: string;
|
||||||
|
oidc_autoprovision?: boolean;
|
||||||
|
oidc_default_role?: string;
|
||||||
|
oidc_button_label?: string;
|
||||||
|
oidc_scopes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SsoTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
issuer?: string;
|
||||||
|
authorization_endpoint?: string;
|
||||||
|
token_endpoint?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ssoService = {
|
||||||
|
async getSettings(): Promise<SsoSettings> {
|
||||||
|
const response = await api.get<SsoSettings>('/admin/settings/sso');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSettings(data: UpdateSsoSettings): Promise<void> {
|
||||||
|
await api.put('/admin/settings/sso', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Server-side discovery probe against the SAVED config.
|
||||||
|
async testConnection(): Promise<SsoTestResult> {
|
||||||
|
const response = await api.post<SsoTestResult>('/admin/settings/sso/test');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user