e91c7deaa4
- OIDC-owned accounts can never authenticate locally: the password login rejects auth_provider='oidc' rows outright (generic 401), and the super-admin password reset refuses them with a clear message — previously a reset would have minted a local password bypassing the IdP's MFA/access policies - /auth/session now returns a full adminUser payload (role join) and AdminAuthContext hydrates user state from it: an SSO redirect establishes the session without any login JSON, which left the header identity blank and current-admin form defaults empty - the /sso/login error path redirects absolute to the frontend base (same split-origin reasoning as the callback) - docker-compose.yml passes API_URL through to the backend (production compose uses env_file and needs nothing; dev compose is gitignored) - authSession.symmetry test mock taught the joined admin lookup (leftJoin, prefixed columns, aliases) — the route change made the old mock throw, which read as "table missing, trust token" Tests: new case pins that a known-good password on an OIDC-owned row still gets 401. 14/14 OIDC, 13/13 symmetry.
303 lines
13 KiB
JavaScript
303 lines
13 KiB
JavaScript
/**
|
|
* 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: 'jit@example.com', 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: 'jit@example.com' }).first();
|
|
expect(row).toBeTruthy();
|
|
expect(row.auth_provider).toBe('oidc');
|
|
expect(row.external_subject).toBe('sub-jit-1');
|
|
|
|
const role = await db('roles').where('id', row.role_id).first();
|
|
expect(role.name).toBe('viewer');
|
|
|
|
// The session JWT must be a normal admin token.
|
|
const token = adminCookie.split(';')[0].replace('admin_token=', '');
|
|
const decoded = jwt.verify(decodeURIComponent(token), process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
|
expect(decoded.type).toBe('admin');
|
|
expect(decoded.id).toBe(row.id);
|
|
agentCookies.jitAdminId = row.id;
|
|
});
|
|
|
|
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
|
|
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
|
|
const res = await ssoRoundTrip();
|
|
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
|
|
|
// No second row — resolved via external_subject.
|
|
expect(await db('admin_users').where({ email: 'renamed@example.com' }).first()).toBeFalsy();
|
|
const byId = await db('admin_users').where({ id: agentCookies.jitAdminId }).first();
|
|
expect(byId.external_subject).toBe('sub-jit-1');
|
|
});
|
|
|
|
it('links an existing local admin one-time via VERIFIED email and stamps the sub', async () => {
|
|
const role = await db('roles').where({ name: 'admin' }).first();
|
|
const [localId] = await db('admin_users').insert({
|
|
username: 'local-admin',
|
|
email: 'local@example.com',
|
|
password_hash: await bcrypt.hash('LocalPass123', 4),
|
|
role_id: role.id,
|
|
is_active: 1,
|
|
auth_provider: 'local',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
}).returning('id').then((r) => [r[0]?.id || r[0]]);
|
|
|
|
idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', email_verified: true });
|
|
const res = await ssoRoundTrip();
|
|
expect(res.headers.location).toBe('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: 'victim@example.com',
|
|
password_hash: await bcrypt.hash('VictimPass123', 4),
|
|
role_id: role.id,
|
|
is_active: 1,
|
|
auth_provider: 'local',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
});
|
|
|
|
idp.setNextUser({ sub: 'sub-attacker', email: 'victim@example.com', email_verified: false });
|
|
// JIT would need this email but the victim row owns it (unique) — the
|
|
// insert fails and the flow must land on an error, never on the
|
|
// victim's session.
|
|
const res = await ssoRoundTrip();
|
|
expect(res.headers.location).toMatch(/sso_error=/);
|
|
|
|
const victim = await db('admin_users').where({ email: 'victim@example.com' }).first();
|
|
expect(victim.external_subject).toBeNull();
|
|
});
|
|
|
|
it('refuses a deactivated admin with sso_error=inactive', async () => {
|
|
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
|
idp.setNextUser({ sub: 'sub-jit-1', email: 'renamed@example.com', email_verified: true });
|
|
const res = await ssoRoundTrip();
|
|
expect(res.headers.location).toBe('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: 'nonce@example.com', 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: 'nonce@example.com' }).first()).toBeFalsy();
|
|
});
|
|
|
|
it('blocks JIT with sso_error=not_provisioned when autoprovision is off', async () => {
|
|
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
|
idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', email_verified: true });
|
|
const res = await ssoRoundTrip();
|
|
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=not_provisioned');
|
|
expect(await db('admin_users').where({ email: 'new@example.com' }).first()).toBeFalsy();
|
|
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
|
});
|
|
|
|
it('stores the client secret encrypted and survives a config round-trip', async () => {
|
|
const row = await db('app_settings').where({ setting_key: 'oidc_client_secret' }).first();
|
|
const stored = JSON.parse(row.setting_value);
|
|
expect(stored).not.toContain(idp.clientSecret);
|
|
expect(oidcService.decryptSecret(stored)).toBe(idp.clientSecret);
|
|
|
|
const cfg = await oidcService.getOidcConfig();
|
|
expect(cfg.clientSecret).toBe(idp.clientSecret);
|
|
});
|
|
|
|
it('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: 'userinfo@example.com', 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: 'userinfo@example.com' }).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: 'colliding@example.com', 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: 'colliding@example.com' }).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,
|
|
});
|
|
}
|
|
});
|
|
});
|