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: 'sso@example.com', 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: 'jit@example.com', 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: 'jit@example.com' }).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: 'renamed@example.com', 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: 'renamed@example.com' }).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: 'local@example.com',
|
||||
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: 'local@example.com', 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: 'victim@example.com',
|
||||
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: 'victim@example.com', 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: 'victim@example.com' }).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: 'renamed@example.com', 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: 'nonce@example.com', 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: 'nonce@example.com' }).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: 'new@example.com', 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: 'new@example.com' }).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",
|
||||
"version": "3.80.0-beta.0",
|
||||
"version": "3.88.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.80.0-beta.0",
|
||||
"version": "3.88.0-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -40,6 +40,7 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"openid-client": "^5.7.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
@@ -7905,6 +7906,15 @@
|
||||
"@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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
@@ -9330,6 +9340,15 @@
|
||||
"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": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -9342,6 +9361,15 @@
|
||||
"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": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
@@ -9404,6 +9432,39 @@
|
||||
"license": "MIT",
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"node-cron": "^3.0.2",
|
||||
"node-stream-zip": "^1.15.0",
|
||||
"nodemailer": "^9.0.1",
|
||||
"openid-client": "^5.7.1",
|
||||
"otplib": "^12.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"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
|
||||
// setup_token is the first-run bootstrap secret. Every handler that loops
|
||||
// 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) => {
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settings[key];
|
||||
@@ -376,6 +379,110 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
try {
|
||||
const { type } = req.params;
|
||||
|
||||
+127
-13
@@ -42,7 +42,7 @@ const router = express.Router();
|
||||
* both produce an identical session. `lockoutKey` is the identifier the user
|
||||
* 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);
|
||||
|
||||
// A normal login means the first-run wizard is over — the wizard never hits
|
||||
@@ -75,18 +75,21 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
|
||||
|
||||
setAdminAuthCookie(res, token);
|
||||
|
||||
return res.json({
|
||||
user: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : null
|
||||
}
|
||||
});
|
||||
return {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
email: admin.email,
|
||||
mustChangePassword: admin.must_change_password || false,
|
||||
role: admin.role_name ? {
|
||||
name: admin.role_name,
|
||||
displayName: admin.role_display_name
|
||||
} : 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
|
||||
@@ -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;
|
||||
|
||||
@@ -27,7 +27,12 @@ router.get('/', async (req, res) => {
|
||||
// backend route also enforces it via getMaxFilesPerUpload,
|
||||
// but a client-side guard saves a 4MB+ round-trip when the
|
||||
// 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');
|
||||
@@ -128,6 +133,10 @@ router.get('/', async (req, res) => {
|
||||
crm_overview_show_outstanding: settingsObject.crm_overview_show_outstanding !== false,
|
||||
crm_overview_show_quotes: settingsObject.crm_overview_show_quotes !== 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',
|
||||
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
|
||||
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 { AccountingTab } from './tabs/AccountingTab';
|
||||
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.",
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
@@ -3706,6 +3732,16 @@
|
||||
"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.",
|
||||
"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": {
|
||||
|
||||
@@ -1637,6 +1637,32 @@
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -3595,6 +3621,16 @@
|
||||
"sessionExpired": "Your verification session expired. Please sign in again.",
|
||||
"locked": "Account temporarily locked due to too many attempts. Try again later.",
|
||||
"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": {
|
||||
|
||||
@@ -61,6 +61,16 @@ export const AdminLoginPage: React.FC = () => {
|
||||
}
|
||||
}, [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.
|
||||
const { data: setupStatus } = useQuery({
|
||||
queryKey: ['setup-status'],
|
||||
@@ -337,6 +347,31 @@ export const AdminLoginPage: React.FC = () => {
|
||||
>
|
||||
{t('adminLogin.signIn')}
|
||||
</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 onSubmit={handleMfaSubmit} className="space-y-6">
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
WebhooksTab,
|
||||
AccountingTab,
|
||||
WhatsAppTab,
|
||||
SsoTab,
|
||||
} from '../../features/settings';
|
||||
import { EmailConfigPage } from './EmailConfigPage';
|
||||
import { BrandingPage } from './BrandingPage';
|
||||
@@ -72,6 +73,7 @@ type TabType =
|
||||
| 'email'
|
||||
| 'moderation'
|
||||
| 'security'
|
||||
| 'sso'
|
||||
| 'imageSecurity'
|
||||
| 'seo'
|
||||
| 'apiTokens'
|
||||
@@ -104,7 +106,7 @@ const ALL_TAB_KEYS: TabType[] = [
|
||||
'features', 'general', 'events', 'eventTypes',
|
||||
'branding', 'categories', 'thumbnails', 'styling', 'cms',
|
||||
'email', 'moderation',
|
||||
'security', 'imageSecurity', 'seo',
|
||||
'security', 'sso', 'imageSecurity', 'seo',
|
||||
'apiTokens', 'webhooks',
|
||||
'status', 'analytics', 'backup',
|
||||
'businessProfile', 'crm', 'contracts', 'reminderTemplates', 'accounting', 'whatsapp',
|
||||
@@ -260,6 +262,7 @@ export const SettingsPage: React.FC = () => {
|
||||
label: t('settings.groups.privacySecurity', 'Privacy & Security'),
|
||||
items: [
|
||||
{ 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: 'seo', label: t('settings.seo.title', 'SEO & Robots'), icon: Search },
|
||||
],
|
||||
@@ -472,6 +475,8 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'sso' && <SsoTab />}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<SecurityTab
|
||||
securitySettings={securitySettings}
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface PublicSettings {
|
||||
seo_meta_noindex?: boolean;
|
||||
seo_meta_nofollow?: 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 = {
|
||||
|
||||
@@ -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