From ed5fc5ad5c468c638aba9e71b8665ab2dccb679a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 16 Jul 2026 08:54:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20OIDC=20SSO=20for=20admin=20users?= =?UTF-8?q?=20=E2=80=94=20phase=201=20(#798)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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. --- .../integration/helpers/mockOidcProvider.js | 150 ++++++++ backend/__tests__/integration/oidcSso.test.js | 226 +++++++++++ .../core/162_admin_oidc_identity.js | 39 ++ backend/package-lock.json | 65 +++- backend/package.json | 1 + backend/src/routes/adminSettings.js | 109 +++++- backend/src/routes/auth.js | 140 ++++++- backend/src/routes/publicSettings.js | 11 +- backend/src/services/oidcService.js | 359 ++++++++++++++++++ frontend/src/features/settings/index.ts | 1 + .../src/features/settings/tabs/SsoTab.tsx | 251 ++++++++++++ frontend/src/i18n/locales/de.json | 36 ++ frontend/src/i18n/locales/en.json | 36 ++ frontend/src/pages/admin/AdminLoginPage.tsx | 35 ++ frontend/src/pages/admin/SettingsPage.tsx | 7 +- .../src/services/publicSettings.service.ts | 3 + frontend/src/services/sso.service.ts | 56 +++ 17 files changed, 1507 insertions(+), 18 deletions(-) create mode 100644 backend/__tests__/integration/helpers/mockOidcProvider.js create mode 100644 backend/__tests__/integration/oidcSso.test.js create mode 100644 backend/migrations/core/162_admin_oidc_identity.js create mode 100644 backend/src/services/oidcService.js create mode 100644 frontend/src/features/settings/tabs/SsoTab.tsx create mode 100644 frontend/src/services/sso.service.ts diff --git a/backend/__tests__/integration/helpers/mockOidcProvider.js b/backend/__tests__/integration/helpers/mockOidcProvider.js new file mode 100644 index 00000000..f50b5374 --- /dev/null +++ b/backend/__tests__/integration/helpers/mockOidcProvider.js @@ -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 }; diff --git a/backend/__tests__/integration/oidcSso.test.js b/backend/__tests__/integration/oidcSso.test.js new file mode 100644 index 00000000..b7f97c87 --- /dev/null +++ b/backend/__tests__/integration/oidcSso.test.js @@ -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 }); + }); +}); diff --git a/backend/migrations/core/162_admin_oidc_identity.js b/backend/migrations/core/162_admin_oidc_identity.js new file mode 100644 index 00000000..41a16a26 --- /dev/null +++ b/backend/migrations/core/162_admin_oidc_identity.js @@ -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)); + } + } +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 87fbe8ea..589562a6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index d64d7a15..1febdaa4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 433a8389..7f4c60dc 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 03f9fa21..1318bbe0 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index a66c92b3..4c440c19 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -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', diff --git a/backend/src/services/oidcService.js b/backend/src/services/oidcService.js new file mode 100644 index 00000000..4e2887ee --- /dev/null +++ b/backend/src/services/oidcService.js @@ -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, +}; diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index 1cba2f58..3013b13a 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -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'; diff --git a/frontend/src/features/settings/tabs/SsoTab.tsx b/frontend/src/features/settings/tabs/SsoTab.tsx new file mode 100644 index 00000000..1a662191 --- /dev/null +++ b/frontend/src/features/settings/tabs/SsoTab.tsx @@ -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(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 ; + } + + const set = (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 ( +
+ +
+
+ +

+ {t('settings.sso.title', 'Single Sign-On (OIDC)')} +

+
+

+ {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.')} +

+ + {/* Redirect URI for the IdP client registration */} +
+

+ {t('settings.sso.redirectUri', 'Redirect URI (register this on your IdP client)')} +

+
+ + {form.redirect_uri} + + +
+
+ + set('oidc_issuer_url', e.target.value)} + /> +

+ {t('settings.sso.issuerHint', 'The base URL that serves /.well-known/openid-configuration.')} +

+ +
+ set('oidc_client_id', e.target.value)} + /> +
+ setNewSecret(e.target.value)} + /> +

+ {t('settings.sso.secretHint', 'Stored encrypted; never shown again. Leave blank to keep the current one.')} +

+
+
+ + set('oidc_scopes', e.target.value)} + /> + + + + {form.oidc_autoprovision && ( +
+ + +
+ )} + + set('oidc_button_label', e.target.value)} + /> + + + +
+ + +
+

+ {t('settings.sso.testHint', 'Test runs OIDC discovery against the saved configuration — save first.')} +

+
+
+
+ ); +}; + +SsoTab.displayName = 'SsoTab'; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index e5eb44ba..85a080bb 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e7c762e0..c2da36f1 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index 52173d69..e5010d0a 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -61,6 +61,16 @@ export const AdminLoginPage: React.FC = () => { } }, [searchParams, t]); + // SSO callback failures land here as ?sso_error= (#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')} + + {/* 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 && ( + <> +
+
+ + {t('adminLogin.ssoDivider', 'or')} + +
+
+ + + )} ) : (
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx index 7c5ecd71..c9d66e91 100644 --- a/frontend/src/pages/admin/SettingsPage.tsx +++ b/frontend/src/pages/admin/SettingsPage.tsx @@ -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' && } + {activeTab === 'security' && ( { + const response = await api.get('/admin/settings/sso'); + return response.data; + }, + + async updateSettings(data: UpdateSsoSettings): Promise { + await api.put('/admin/settings/sso', data); + }, + + // Server-side discovery probe against the SAVED config. + async testConnection(): Promise { + const response = await api.post('/admin/settings/sso/test'); + return response.data; + }, +};