Merge pull request #806 from PicPeak/feat/oidc-sso-phase1
feat(auth): OIDC SSO for admin users — phase 1
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Minimal in-process OIDC provider for integration tests (#798).
|
||||
*
|
||||
* Serves just enough of the spec for openid-client's full validation to
|
||||
* pass: discovery, JWKS (RS256), authorization endpoint (immediate redirect,
|
||||
* no login UI), and token endpoint (authorization_code + PKCE). Claims for
|
||||
* the next login are scripted per test via `setNextUser()`.
|
||||
*
|
||||
* Runs on an ephemeral localhost port over plain http — the service allows
|
||||
* that in NODE_ENV=test only.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
const { URL } = require('url');
|
||||
|
||||
function b64url(input) {
|
||||
return Buffer.from(input).toString('base64url');
|
||||
}
|
||||
|
||||
class MockOidcProvider {
|
||||
constructor() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
this.privateKey = privateKey;
|
||||
this.publicJwk = publicKey.export({ format: 'jwk' });
|
||||
this.publicJwk.kid = 'test-key-1';
|
||||
this.publicJwk.alg = 'RS256';
|
||||
this.publicJwk.use = 'sig';
|
||||
|
||||
this.clientId = 'picpeak-test';
|
||||
this.clientSecret = 'test-client-secret';
|
||||
this.codes = new Map(); // code -> { nonce, redirectUri, codeChallenge, user }
|
||||
this.nextUser = { sub: 'user-1', email: '[email protected]', email_verified: true };
|
||||
// Test hooks:
|
||||
this.tamperNonce = false; // sign the ID token with a WRONG nonce
|
||||
this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo
|
||||
this.accessTokens = new Map(); // access_token -> user (for /userinfo)
|
||||
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`,
|
||||
userinfo_endpoint: `${this.issuer}/userinfo`,
|
||||
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;
|
||||
// Spec-compliant providers may keep profile/email claims OFF the ID
|
||||
// token and serve them from /userinfo only — this hook simulates that.
|
||||
const idTokenClaims = this.emailViaUserinfoOnly ? {} : extraClaims;
|
||||
const idToken = this.signIdToken({
|
||||
sub,
|
||||
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
||||
extraClaims: idTokenClaims,
|
||||
});
|
||||
const accessToken = crypto.randomBytes(16).toString('base64url');
|
||||
this.accessTokens.set(accessToken, stored.user);
|
||||
return json(200, {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
id_token: idToken,
|
||||
});
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (url.pathname === '/userinfo') {
|
||||
const auth = req.headers.authorization || '';
|
||||
const user = this.accessTokens.get(auth.replace(/^Bearer\s+/i, ''));
|
||||
if (!user) return json(401, { error: 'invalid_token' });
|
||||
return json(200, { ...user });
|
||||
}
|
||||
|
||||
return json(404, { error: 'not_found' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MockOidcProvider };
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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';
|
||||
// The redirect_uri derives from the public base URL — pin it explicitly:
|
||||
// CI has no backend/.env, and getFrontendBaseUrl() returning '' makes
|
||||
// buildAuthorizationRequest fail (by design) with OIDC_BAD_CONFIG.
|
||||
process.env.FRONTEND_URL = 'http://localhost:5199';
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
idp = new MockOidcProvider();
|
||||
const issuer = await idp.start();
|
||||
|
||||
// Require AFTER bootCrmDb so services share this db instance.
|
||||
oidcService = require('../../src/services/oidcService');
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_enabled: true,
|
||||
oidc_issuer_url: issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
oidc_autoprovision: true,
|
||||
oidc_default_role: 'viewer',
|
||||
});
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use('/api/auth', authRouter);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (idp) await idp.stop();
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
/** Drive login → IdP → callback like a browser; returns the callback response. */
|
||||
async function ssoRoundTrip({ mutateState } = {}) {
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const idpUrl = loginRes.headers.location;
|
||||
expect(idpUrl.startsWith(idp.issuer)).toBe(true);
|
||||
|
||||
let stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state='));
|
||||
expect(stateCookie).toBeTruthy();
|
||||
stateCookie = stateCookie.split(';')[0];
|
||||
if (mutateState === 'drop') stateCookie = null;
|
||||
if (mutateState === 'forge') {
|
||||
stateCookie = `oidc_state=${jwt.sign({ type: 'oidc_state', s: 'x', n: 'y', cv: 'z' }, 'wrong-secret', { issuer: 'picpeak-auth' })}`;
|
||||
}
|
||||
|
||||
// "Browser" follows the redirect to the IdP, which instantly bounces back.
|
||||
const idpRes = await fetch(idpUrl, { redirect: 'manual' });
|
||||
expect(idpRes.status).toBe(302);
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
|
||||
let cb = request(app).get(`${back.pathname}?${back.searchParams.toString()}`);
|
||||
if (stateCookie) cb = cb.set('Cookie', stateCookie);
|
||||
return cb.expect(302);
|
||||
}
|
||||
|
||||
it('JIT-provisions an unknown user and establishes an admin session', async () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
||||
expect(adminCookie).toBeTruthy();
|
||||
|
||||
const row = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.auth_provider).toBe('oidc');
|
||||
expect(row.external_subject).toBe('sub-jit-1');
|
||||
|
||||
const role = await db('roles').where('id', row.role_id).first();
|
||||
expect(role.name).toBe('viewer');
|
||||
|
||||
// The session JWT must be a normal admin token.
|
||||
const token = adminCookie.split(';')[0].replace('admin_token=', '');
|
||||
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
expect(decoded.type).toBe('admin');
|
||||
expect(decoded.id).toBe(row.id);
|
||||
agentCookies.jitAdminId = row.id;
|
||||
});
|
||||
|
||||
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
// No second row — resolved via external_subject.
|
||||
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(byId.external_subject).toBe('sub-jit-1');
|
||||
});
|
||||
|
||||
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
const [localId] = await db('admin_users').insert({
|
||||
username: 'local-admin',
|
||||
email: '[email protected]',
|
||||
password_hash: await bcrypt.hash('LocalPass123', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
||||
|
||||
idp.setNextUser({ sub: 'sub-local-1', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
const row = await db('admin_users').where({ id: localId }).first();
|
||||
expect(row.external_subject).toBe('sub-local-1');
|
||||
expect(row.auth_provider).toBe('local'); // password keeps working
|
||||
});
|
||||
|
||||
it('does NOT link by unverified email — provisions a separate account instead', async () => {
|
||||
const role = await db('roles').where({ name: 'admin' }).first();
|
||||
await db('admin_users').insert({
|
||||
username: 'victim-admin',
|
||||
email: '[email protected]',
|
||||
password_hash: await bcrypt.hash('VictimPass123', 4),
|
||||
role_id: role.id,
|
||||
is_active: 1,
|
||||
auth_provider: 'local',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
idp.setNextUser({ sub: 'sub-attacker', email: '[email protected]', email_verified: false });
|
||||
// JIT would need this email but the victim row owns it (unique) — the
|
||||
// insert fails and the flow must land on an error, never on the
|
||||
// victim's session.
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toMatch(/sso_error=/);
|
||||
|
||||
const victim = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||
expect(victim.external_subject).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a deactivated admin with sso_error=inactive', async () => {
|
||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/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('http://localhost:5199/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('http://localhost:5199/admin/login?sso_error=state');
|
||||
});
|
||||
|
||||
it('rejects an ID token whose nonce does not match', async () => {
|
||||
idp.tamperNonce = true;
|
||||
idp.setNextUser({ sub: 'sub-nonce', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
idp.tamperNonce = false;
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
|
||||
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
|
||||
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
||||
idp.setNextUser({ sub: 'sub-new-user', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
|
||||
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
||||
});
|
||||
|
||||
it('stores the client secret encrypted and survives a config round-trip', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
|
||||
const stored = JSON.parse(row.setting_value);
|
||||
expect(stored).not.toContain(idp.clientSecret);
|
||||
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
|
||||
|
||||
const cfg = await oidcService.getOidcConfig();
|
||||
expect(cfg.clientSecret).toBe(idp.clientSecret);
|
||||
});
|
||||
|
||||
it('refuses local password login for OIDC-owned accounts', async () => {
|
||||
// Give the JIT admin a KNOWN password hash directly in the DB — the
|
||||
// auth_provider check must reject the login even with valid credentials
|
||||
// (otherwise a password reset would mint an IdP-bypassing local login).
|
||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({
|
||||
password_hash: await bcrypt.hash('KnownPass123', 4),
|
||||
});
|
||||
const row = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: row.email, password: 'KnownPass123' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it('merges email from the UserInfo endpoint when the ID token omits it', async () => {
|
||||
idp.emailViaUserinfoOnly = true;
|
||||
idp.setNextUser({ sub: 'sub-userinfo', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
idp.emailViaUserinfoOnly = false;
|
||||
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
const row = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(row.external_subject).toBe('sub-userinfo');
|
||||
});
|
||||
|
||||
it('binds identities per ISSUER — a sub collision on a new IdP must not inherit the old account', async () => {
|
||||
// The JIT admin from the first test is bound to (issuer A, 'sub-jit-1').
|
||||
const boundAdmin = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(boundAdmin.external_issuer).toBe(idp.issuer);
|
||||
|
||||
// Same sub, DIFFERENT issuer: a second IdP the instance switches to.
|
||||
const idp2 = new MockOidcProvider();
|
||||
await idp2.start();
|
||||
try {
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: idp2.issuer,
|
||||
oidc_client_id: idp2.clientId,
|
||||
oidc_client_secret: idp2.clientSecret,
|
||||
});
|
||||
idp2.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
|
||||
const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302);
|
||||
const stateCookie = (loginRes.headers['set-cookie'] || [])
|
||||
.find((c) => c.startsWith('oidc_state=')).split(';')[0];
|
||||
const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' });
|
||||
const back = new URL(idpRes.headers.get('location'));
|
||||
const res = await request(app)
|
||||
.get(`${back.pathname}?${back.searchParams.toString()}`)
|
||||
.set('Cookie', stateCookie)
|
||||
.expect(302);
|
||||
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||
|
||||
// A NEW row bound to issuer B — the issuer-A admin is untouched and
|
||||
// its role was not inherited.
|
||||
const collider = await db('admin_users').where({ email: '[email protected]' }).first();
|
||||
expect(collider).toBeTruthy();
|
||||
expect(collider.id).not.toBe(agentCookies.jitAdminId);
|
||||
expect(collider.external_issuer).toBe(idp2.issuer);
|
||||
const original = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
||||
expect(original.external_issuer).toBe(idp.issuer);
|
||||
} finally {
|
||||
await idp2.stop();
|
||||
await oidcService.saveOidcSettings({
|
||||
oidc_issuer_url: idp.issuer,
|
||||
oidc_client_id: idp.clientId,
|
||||
oidc_client_secret: idp.clientSecret,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,9 +32,17 @@ jest.mock('../../src/database/db', () => {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
// The session route joins roles for the adminUser payload (#798);
|
||||
// fake rows carry no role fields, so the join is a pass-through.
|
||||
leftJoin() {
|
||||
return this;
|
||||
},
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([k, v]) => {
|
||||
return Object.entries(criteria).every(([rawKey, v]) => {
|
||||
// Joined queries prefix columns ('admin_users.id') — the fake
|
||||
// rows use bare names.
|
||||
const k = rawKey.replace(/^admin_users\./, '');
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
@@ -50,7 +58,12 @@ jest.mock('../../src/database/db', () => {
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) out[c] = row[c];
|
||||
for (const c of this._cols) {
|
||||
// Support 'table.col' and 'table.col as alias' shapes.
|
||||
const [source, alias] = c.split(/\s+as\s+/i);
|
||||
const bare = source.includes('.') ? source.split('.').pop() : source;
|
||||
out[alias || bare] = row[bare];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Migration 162: OIDC identity binding for admin users (#798).
|
||||
*
|
||||
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
|
||||
* account's credentials.
|
||||
* - `external_issuer` — the validated `iss` of the IdP that owns the subject.
|
||||
* OIDC only guarantees `sub` uniqueness WITHIN an
|
||||
* issuer, so bindings match on (iss, sub) — otherwise
|
||||
* switching `oidc_issuer_url` could map a new
|
||||
* provider's user onto an old provider's admin when
|
||||
* their subjects collide.
|
||||
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
|
||||
* SSO logins match on (external_issuer,
|
||||
* external_subject), NEVER on email alone —
|
||||
* email-matching is an account-takeover vector with
|
||||
* IdPs that don't verify addresses. Nullable: local
|
||||
* accounts have neither.
|
||||
*
|
||||
* Composite unique index so one IdP identity 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_issuer'))) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.string('external_issuer', 512).nullable();
|
||||
});
|
||||
}
|
||||
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
t.string('external_subject', 255).nullable();
|
||||
t.unique(['external_issuer', 'external_subject'], {
|
||||
indexName: 'admin_users_issuer_subject_unique',
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const col of ['external_subject', 'external_issuer', '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",
|
||||
|
||||
@@ -47,6 +47,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",
|
||||
|
||||
@@ -77,6 +77,11 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
|
||||
// at all while maintenance mode is on.
|
||||
'/api/auth/admin/login/mfa',
|
||||
// SSO variants of the admin login (#798) — same reasoning: an SSO-only
|
||||
// (JIT-provisioned) admin has no password, so blocking these would make
|
||||
// maintenance mode admin-proof for them.
|
||||
'/api/auth/admin/sso/login',
|
||||
'/api/auth/admin/sso/callback',
|
||||
'/api/auth/session',
|
||||
'/api/public/settings',
|
||||
'/health'
|
||||
|
||||
@@ -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];
|
||||
@@ -156,6 +159,14 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// Reserved bootstrap/credential keys are NEVER readable through the
|
||||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settingsObject[key];
|
||||
}
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
@@ -386,6 +397,124 @@ 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();
|
||||
// No public base URL configured → surface an empty redirect_uri rather
|
||||
// than failing the whole settings read; the login route refuses to start
|
||||
// the flow in that state anyway (OIDC_BAD_CONFIG).
|
||||
const redirectUri = await oidcService.getRedirectUri().catch(() => '');
|
||||
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: redirectUri,
|
||||
});
|
||||
} 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');
|
||||
|
||||
// Validate the MERGED resulting state, not just the request: enabling
|
||||
// requires a complete config, and a partial PUT must not be able to
|
||||
// blank the issuer/client while a stored enabled=true keeps a login
|
||||
// button alive that can only fail.
|
||||
const current = await oidcService.getOidcConfig();
|
||||
const effectiveEnabled = req.body.oidc_enabled ?? current.enabled;
|
||||
if (effectiveEnabled === true) {
|
||||
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 while SSO is enabled — disable SSO first to clear them' });
|
||||
}
|
||||
// The redirect URI must be derivable too, or the login button leads
|
||||
// straight to an error (needs API_URL / FRONTEND_URL / general_site_url).
|
||||
try {
|
||||
await oidcService.getRedirectUri();
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -416,6 +545,14 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Reserved bootstrap/credential keys are NEVER readable through the
|
||||
// generic settings reads — oidc_client_secret (#798) is stored encrypted
|
||||
// with setting_type 'string' and would otherwise leak its ciphertext to
|
||||
// any settings.view holder; setup_token is the first-run bootstrap secret.
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settingsObject[key];
|
||||
}
|
||||
|
||||
// Mask sensitive secrets before sending to client
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
|
||||
+171
-18
@@ -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
|
||||
@@ -140,8 +143,11 @@ router.post('/admin/login', [
|
||||
)
|
||||
.first();
|
||||
|
||||
// Use generic error to prevent user enumeration
|
||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
// Use generic error to prevent user enumeration. OIDC-owned accounts
|
||||
// (#798) never authenticate locally — their random hash is unusable by
|
||||
// design, and the explicit check keeps that true even if a hash ever
|
||||
// gets set through some other path.
|
||||
if (!admin || admin.auth_provider === 'oidc' || !await bcrypt.compare(password, admin.password_hash)) {
|
||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: getGenericAuthError() });
|
||||
}
|
||||
@@ -661,12 +667,22 @@ router.get('/session', async (req, res) => {
|
||||
// or the gallery event was archived/deleted. Mirror those checks
|
||||
// here so the session endpoint is always at least as strict as
|
||||
// what the protected endpoints will enforce next.
|
||||
// Full user payload for admin sessions — the SSO callback establishes
|
||||
// the session via redirect (no JSON response the SPA could store), so
|
||||
// session restoration must be able to hydrate the user object (#798).
|
||||
let adminUser = null;
|
||||
|
||||
if (decoded.type === 'admin') {
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
|
||||
.select(
|
||||
'admin_users.id', 'admin_users.username', 'admin_users.email',
|
||||
'admin_users.password_changed_at', 'admin_users.must_change_password',
|
||||
'roles.name as role_name', 'roles.display_name as role_display_name'
|
||||
)
|
||||
.first();
|
||||
} catch (lookupErr) {
|
||||
// admin_users table not present (test fixture, fresh DB) — fall
|
||||
@@ -705,6 +721,19 @@ router.get('/session', async (req, res) => {
|
||||
// Helper lookup failed (test stub may not export it) — fall through
|
||||
// and trust the token. Real deployments always have the middleware.
|
||||
}
|
||||
|
||||
if (admin) {
|
||||
adminUser = {
|
||||
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
|
||||
};
|
||||
}
|
||||
} else if (decoded.type === 'gallery') {
|
||||
try {
|
||||
const event = await db('events')
|
||||
@@ -736,7 +765,11 @@ router.get('/session', async (req, res) => {
|
||||
expiresIn: Math.floor(remainingTime),
|
||||
user: decoded.username || decoded.eventSlug,
|
||||
eventSlug: decoded.eventSlug,
|
||||
adminUsername: decoded.username
|
||||
adminUsername: decoded.username,
|
||||
// Full admin payload (or null) — lets the SPA hydrate its user
|
||||
// state after a redirect-established session (SSO, #798) where no
|
||||
// login JSON response ever reached it.
|
||||
adminUser
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({
|
||||
@@ -858,4 +891,124 @@ 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 });
|
||||
// Absolute like the callback's redirects: in split-origin deployments a
|
||||
// relative path would resolve on the API origin and 404.
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
|
||||
return res.redirect(`${frontendBase}/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.
|
||||
// Final redirects are ABSOLUTE to the frontend base: in split-origin
|
||||
// deployments (absolute VITE_API_URL / API_URL) this callback runs on the
|
||||
// API origin, where a relative /admin/login would 404.
|
||||
router.get('/admin/sso/callback', async (req, res) => {
|
||||
const oidcService = require('../services/oidcService');
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const frontendBase = (await getFrontendBaseUrl().catch(() => '')) || '';
|
||||
const fail = (key) => res.redirect(`${frontendBase}/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(`${frontendBase}/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,440 @@
|
||||
/**
|
||||
* 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) {
|
||||
// The secret is part of the key (as a fingerprint, never plaintext): in
|
||||
// multi-worker deployments a secret rotation only invalidates the cache in
|
||||
// the worker that handled the settings request — the others must detect
|
||||
// the change through the key, or they keep signing with the old secret.
|
||||
const secretFp = crypto.createHash('sha256').update(cfg.clientSecret || '').digest('hex').slice(0, 16);
|
||||
const key = `${cfg.issuerUrl}|${cfg.clientId}|${secretFp}`;
|
||||
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() {
|
||||
// The callback must land on the API's public origin — that is where the
|
||||
// oidc_state cookie was set when the browser hit /sso/login. In the
|
||||
// standard deployment the frontend proxies /api on the same origin, so
|
||||
// FRONTEND_URL works; split-origin deployments set API_URL (canonically
|
||||
// ending in /api, see .env.example) and MUST be honored first or the
|
||||
// callback goes to a host that has neither the route nor the cookie.
|
||||
const apiBase = (process.env.API_URL || '').trim().replace(/\/$/, '');
|
||||
if (apiBase) {
|
||||
return `${apiBase}/auth/admin/sso/callback`;
|
||||
}
|
||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
|
||||
if (!base) {
|
||||
// Without a public base URL the redirect_uri would be relative — the IdP
|
||||
// would reject it with an opaque error on ITS side. Fail here with a
|
||||
// clear config message instead.
|
||||
const err = new Error('API_URL or FRONTEND_URL (or the general_site_url setting) must be set for SSO');
|
||||
err.code = 'OIDC_BAD_CONFIG';
|
||||
throw err;
|
||||
}
|
||||
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, issuerMetadata } = 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,
|
||||
});
|
||||
|
||||
let claims = tokenSet.claims();
|
||||
|
||||
// Spec-compliant providers may deliver `profile`/`email` scope claims only
|
||||
// from the UserInfo endpoint, not inside the ID token. When the email is
|
||||
// missing there, fetch UserInfo and merge — ID-token claims win on
|
||||
// conflict (they are signature-bound to this very authorization). The sub
|
||||
// must match, or the response is discarded (spec requirement).
|
||||
if (!claims.email && issuerMetadata.userinfo_endpoint && tokenSet.access_token) {
|
||||
try {
|
||||
const userinfo = await client.userinfo(tokenSet);
|
||||
if (userinfo && userinfo.sub === claims.sub) {
|
||||
claims = { ...userinfo, ...claims };
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-fatal: providers that put everything in the ID token don't need
|
||||
// this; resolveAdminFromClaims handles a still-missing email.
|
||||
logger.warn('OIDC userinfo fetch failed — proceeding with ID token claims only', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated ID token claims to an admin_users row.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. (external_issuer, external_subject) === (iss, sub) → that admin
|
||||
* (must be active). Matching includes the issuer because OIDC only
|
||||
* guarantees sub uniqueness WITHIN an issuer — a lookup on sub alone
|
||||
* would let a user of a newly-configured IdP inherit an old IdP's
|
||||
* admin account on a subject collision.
|
||||
* 2. email match against an UNLINKED admin, only if email_verified === true
|
||||
* → one-time link (stamps issuer+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 iss = claims.iss;
|
||||
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
|
||||
const emailVerified = claims.email_verified === true;
|
||||
|
||||
if (!sub || !iss) {
|
||||
const err = new Error('ID token has no sub/iss claim');
|
||||
err.code = 'OIDC_BAD_CLAIMS';
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 1. Established binding — issuer AND subject.
|
||||
const bySub = await db('admin_users')
|
||||
.where('external_issuer', iss)
|
||||
.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 identity 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;
|
||||
}
|
||||
// Claim atomically: two concurrent first-time callbacks with the same
|
||||
// email but DIFFERENT subjects must not both authenticate as this
|
||||
// admin — the conditional update lets exactly one win.
|
||||
const claimed = await db('admin_users')
|
||||
.where('id', byEmail.id)
|
||||
.whereNull('external_subject')
|
||||
.update({
|
||||
external_issuer: iss,
|
||||
external_subject: sub,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
if (claimed !== 1) {
|
||||
// Lost the race. If the winner was this very identity (double-click,
|
||||
// parallel tabs), the binding lookup now succeeds; anything else is
|
||||
// an unbound identity again and must not proceed as this admin.
|
||||
const rebound = await db('admin_users')
|
||||
.where('external_issuer', iss)
|
||||
.where('external_subject', sub)
|
||||
.first();
|
||||
if (rebound && rebound.is_active) return rebound;
|
||||
const err = new Error('Account link raced with another sign-in — try again');
|
||||
err.code = 'OIDC_BAD_CLAIMS';
|
||||
throw err;
|
||||
}
|
||||
logger.info('OIDC: linked existing admin to IdP subject', {
|
||||
adminId: byEmail.id,
|
||||
sub,
|
||||
});
|
||||
return { ...byEmail, external_issuer: iss, 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_issuer: iss,
|
||||
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) {
|
||||
// The `openid` scope is what makes this OIDC rather than plain OAuth —
|
||||
// without it there is no ID token and the callback cannot authenticate
|
||||
// anyone. Force it in rather than trusting the admin's edit.
|
||||
const scopes = String(input.oidc_scopes).trim().split(/\s+/).filter(Boolean);
|
||||
if (!scopes.includes('openid')) scopes.unshift('openid');
|
||||
put('oidc_scopes', scopes.join(' ') || '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,
|
||||
};
|
||||
@@ -459,6 +459,13 @@ async function resetAdminPassword(id, resetById) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
}
|
||||
|
||||
// OIDC-owned accounts (#798) have no usable local password by design —
|
||||
// minting one here would hand out a login that bypasses the IdP's MFA
|
||||
// and access policies.
|
||||
if (user.auth_provider === 'oidc') {
|
||||
throw new ValidationError('This account is managed by your identity provider (SSO) — reset the password there.');
|
||||
}
|
||||
|
||||
const newPassword = generateReadablePassword();
|
||||
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- EMAIL_FROM=${EMAIL_FROM:[email protected]}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||
# Public API origin for split-origin deployments (#798 SSO redirect_uri).
|
||||
# Empty = same origin as FRONTEND_URL (the standard proxied setup).
|
||||
- API_URL=${API_URL:-}
|
||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||
- TZ=${TZ:-UTC}
|
||||
- STORAGE_PATH=/app/storage
|
||||
|
||||
@@ -50,12 +50,19 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string }>(
|
||||
const response = await api.get<{ valid: boolean; type: string; adminUsername?: string; user?: string; adminUser?: AdminUser | null }>(
|
||||
'/auth/session'
|
||||
);
|
||||
|
||||
if (response.data?.valid && response.data.type === 'admin') {
|
||||
setIsAuthenticated(true);
|
||||
// Redirect-established sessions (SSO, #798) never went through
|
||||
// login(), so sessionStorage has no user — hydrate from the
|
||||
// session payload. Server truth also refreshes stale local copies.
|
||||
if (response.data.adminUser) {
|
||||
setUser(response.data.adminUser);
|
||||
sessionStorage.setItem('admin_user', JSON.stringify(response.data.adminUser));
|
||||
}
|
||||
} else {
|
||||
sessionStorage.removeItem('admin_user');
|
||||
setIsAuthenticated(false);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { setupService } from '../../services/setup.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
export const AdminLoginPage: React.FC = () => {
|
||||
@@ -61,6 +62,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 +348,35 @@ 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" />}
|
||||
// buildResourceUrl respects an absolute VITE_API_URL, so
|
||||
// split-origin deployments start the flow on the API host
|
||||
// (where the state cookie must live) instead of 404ing on
|
||||
// the frontend origin.
|
||||
onClick={() => { window.location.href = buildResourceUrl('/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