fix(oidc): security + robustness hardening from codex review rounds 1-2
Round 1: - bind SSO identities to (external_issuer, external_subject): OIDC only guarantees sub uniqueness within an issuer, so a sub-only lookup let a newly configured IdP's user inherit an old IdP's admin account on subject collision; migration 162 gains external_issuer + composite unique index (unmerged migration, edited in place) - fetch UserInfo (with sub cross-check) when the ID token carries no email — spec-compliant providers may serve email/profile claims only there; ID-token claims win on merge - allowlist /admin/sso/login + /callback in maintenance mode, or SSO-only (JIT) admins are locked out exactly when they need in - strip reserved keys (oidc_client_secret, setup_token) from BOTH generic settings reads (GET / and GET /:type) Round 2: - redirect_uri prefers API_URL (the API's public origin — where the state cookie lives); final redirects absolute to the frontend base; login button builds its URL via buildResourceUrl — split-origin deployments (absolute VITE_API_URL) work end to end - PUT /sso validates the MERGED resulting state (partial update cannot blank issuer/client while enabled=true survives; enabling requires a derivable redirect URI) - openid scope forced into oidc_scopes on save - discovery-cache key includes a secret fingerprint (multi-worker secret rotation) - email→admin linking claims the row atomically (conditional update on external_subject IS NULL) — concurrent first-time callbacks with the same verified email but different subjects can't both authenticate Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode; new cases pin the userinfo merge and issuer-collision non-inheritance; redirect assertions updated for absolute URLs. 13/13.
This commit is contained in:
@@ -33,6 +33,8 @@ class MockOidcProvider {
|
|||||||
this.nextUser = { sub: 'user-1', email: '[email protected]', email_verified: true };
|
this.nextUser = { sub: 'user-1', email: '[email protected]', email_verified: true };
|
||||||
// Test hooks:
|
// Test hooks:
|
||||||
this.tamperNonce = false; // sign the ID token with a WRONG nonce
|
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.server = null;
|
||||||
this.issuer = null;
|
this.issuer = null;
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,7 @@ class MockOidcProvider {
|
|||||||
issuer: this.issuer,
|
issuer: this.issuer,
|
||||||
authorization_endpoint: `${this.issuer}/authorize`,
|
authorization_endpoint: `${this.issuer}/authorize`,
|
||||||
token_endpoint: `${this.issuer}/token`,
|
token_endpoint: `${this.issuer}/token`,
|
||||||
|
userinfo_endpoint: `${this.issuer}/userinfo`,
|
||||||
jwks_uri: `${this.issuer}/jwks`,
|
jwks_uri: `${this.issuer}/jwks`,
|
||||||
response_types_supported: ['code'],
|
response_types_supported: ['code'],
|
||||||
subject_types_supported: ['public'],
|
subject_types_supported: ['public'],
|
||||||
@@ -128,13 +131,18 @@ class MockOidcProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { sub, ...extraClaims } = stored.user;
|
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({
|
const idToken = this.signIdToken({
|
||||||
sub,
|
sub,
|
||||||
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
nonce: this.tamperNonce ? 'tampered-nonce' : stored.nonce,
|
||||||
extraClaims,
|
extraClaims: idTokenClaims,
|
||||||
});
|
});
|
||||||
|
const accessToken = crypto.randomBytes(16).toString('base64url');
|
||||||
|
this.accessTokens.set(accessToken, stored.user);
|
||||||
return json(200, {
|
return json(200, {
|
||||||
access_token: crypto.randomBytes(16).toString('base64url'),
|
access_token: accessToken,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
expires_in: 300,
|
expires_in: 300,
|
||||||
id_token: idToken,
|
id_token: idToken,
|
||||||
@@ -143,6 +151,13 @@ class MockOidcProvider {
|
|||||||
return undefined;
|
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' });
|
return json(404, { error: 'not_found' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
|
|
||||||
expect(res.headers.location).toBe('/admin/dashboard');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||||
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
const adminCookie = (res.headers['set-cookie'] || []).find((c) => c.startsWith('admin_token='));
|
||||||
expect(adminCookie).toBeTruthy();
|
expect(adminCookie).toBeTruthy();
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
it('matches repeat logins by sub even when the email changed at the IdP', async () => {
|
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 });
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
expect(res.headers.location).toBe('/admin/dashboard');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||||
|
|
||||||
// No second row — resolved via external_subject.
|
// No second row — resolved via external_subject.
|
||||||
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
@@ -145,7 +145,7 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
|
|
||||||
idp.setNextUser({ sub: 'sub-local-1', email: '[email protected]', email_verified: true });
|
idp.setNextUser({ sub: 'sub-local-1', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
expect(res.headers.location).toBe('/admin/dashboard');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard');
|
||||||
|
|
||||||
const row = await db('admin_users').where({ id: localId }).first();
|
const row = await db('admin_users').where({ id: localId }).first();
|
||||||
expect(row.external_subject).toBe('sub-local-1');
|
expect(row.external_subject).toBe('sub-local-1');
|
||||||
@@ -180,18 +180,18 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 0 });
|
||||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
expect(res.headers.location).toBe('/admin/login?sso_error=inactive');
|
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 });
|
await db('admin_users').where({ id: agentCookies.jitAdminId }).update({ is_active: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a callback without the state cookie', async () => {
|
it('rejects a callback without the state cookie', async () => {
|
||||||
const res = await ssoRoundTrip({ mutateState: 'drop' });
|
const res = await ssoRoundTrip({ mutateState: 'drop' });
|
||||||
expect(res.headers.location).toBe('/admin/login?sso_error=state');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a forged state cookie (wrong signing key)', async () => {
|
it('rejects a forged state cookie (wrong signing key)', async () => {
|
||||||
const res = await ssoRoundTrip({ mutateState: 'forge' });
|
const res = await ssoRoundTrip({ mutateState: 'forge' });
|
||||||
expect(res.headers.location).toBe('/admin/login?sso_error=state');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=state');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an ID token whose nonce does not match', async () => {
|
it('rejects an ID token whose nonce does not match', async () => {
|
||||||
@@ -199,7 +199,7 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
idp.setNextUser({ sub: 'sub-nonce', email: '[email protected]', email_verified: true });
|
idp.setNextUser({ sub: 'sub-nonce', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
idp.tamperNonce = false;
|
idp.tamperNonce = false;
|
||||||
expect(res.headers.location).toBe('/admin/login?sso_error=idp');
|
expect(res.headers.location).toBe('http://localhost:5199/admin/login?sso_error=idp');
|
||||||
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
await oidcService.saveOidcSettings({ oidc_autoprovision: false });
|
||||||
idp.setNextUser({ sub: 'sub-new-user', email: '[email protected]', email_verified: true });
|
idp.setNextUser({ sub: 'sub-new-user', email: '[email protected]', email_verified: true });
|
||||||
const res = await ssoRoundTrip();
|
const res = await ssoRoundTrip();
|
||||||
expect(res.headers.location).toBe('/admin/login?sso_error=not_provisioned');
|
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();
|
expect(await db('admin_users').where({ email: '[email protected]' }).first()).toBeFalsy();
|
||||||
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
await oidcService.saveOidcSettings({ oidc_autoprovision: true });
|
||||||
});
|
});
|
||||||
@@ -227,4 +227,61 @@ describe('OIDC SSO (#798)', () => {
|
|||||||
await request(app).get('/api/auth/admin/sso/login').expect(404);
|
await request(app).get('/api/auth/admin/sso/login').expect(404);
|
||||||
await oidcService.saveOidcSettings({ oidc_enabled: true });
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,20 @@
|
|||||||
*
|
*
|
||||||
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
|
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
|
||||||
* account's credentials.
|
* 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`).
|
* - `external_subject` — the IdP's stable subject identifier (OIDC `sub`).
|
||||||
* SSO logins match on (auth_provider, external_subject),
|
* SSO logins match on (external_issuer,
|
||||||
* NEVER on email alone — email-matching is an
|
* external_subject), NEVER on email alone —
|
||||||
* account-takeover vector with IdPs that don't verify
|
* email-matching is an account-takeover vector with
|
||||||
* addresses. Nullable: local accounts have none.
|
* IdPs that don't verify addresses. Nullable: local
|
||||||
|
* accounts have neither.
|
||||||
*
|
*
|
||||||
* Composite unique index so one IdP subject can't map to two admin rows.
|
* Composite unique index so one IdP identity can't map to two admin rows.
|
||||||
* Additive + guarded; existing rows keep working untouched ('local', NULL).
|
* Additive + guarded; existing rows keep working untouched ('local', NULL).
|
||||||
*/
|
*/
|
||||||
exports.up = async function up(knex) {
|
exports.up = async function up(knex) {
|
||||||
@@ -18,18 +25,23 @@ exports.up = async function up(knex) {
|
|||||||
t.string('auth_provider', 20).notNullable().defaultTo('local');
|
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'))) {
|
if (!(await knex.schema.hasColumn('admin_users', 'external_subject'))) {
|
||||||
await knex.schema.alterTable('admin_users', (t) => {
|
await knex.schema.alterTable('admin_users', (t) => {
|
||||||
t.string('external_subject', 255).nullable();
|
t.string('external_subject', 255).nullable();
|
||||||
t.unique(['auth_provider', 'external_subject'], {
|
t.unique(['external_issuer', 'external_subject'], {
|
||||||
indexName: 'admin_users_provider_subject_unique',
|
indexName: 'admin_users_issuer_subject_unique',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.down = async function down(knex) {
|
exports.down = async function down(knex) {
|
||||||
for (const col of ['external_subject', 'auth_provider']) {
|
for (const col of ['external_subject', 'external_issuer', 'auth_provider']) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
if (await knex.schema.hasColumn('admin_users', col)) {
|
if (await knex.schema.hasColumn('admin_users', col)) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ async function maintenanceMiddleware(req, res, next) {
|
|||||||
// entries here matched nothing, which is exactly why the lockout happened).
|
// entries here matched nothing, which is exactly why the lockout happened).
|
||||||
const skipPaths = [
|
const skipPaths = [
|
||||||
'/api/auth/admin/login',
|
'/api/auth/admin/login',
|
||||||
|
// 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/auth/session',
|
||||||
'/api/public/settings',
|
'/api/public/settings',
|
||||||
'/health'
|
'/health'
|
||||||
|
|||||||
@@ -159,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
|
// Mask sensitive secrets before sending to client
|
||||||
if (settingsObject.security_recaptcha_secret_key) {
|
if (settingsObject.security_recaptcha_secret_key) {
|
||||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||||
@@ -428,16 +436,26 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [
|
|||||||
}
|
}
|
||||||
const oidcService = require('../services/oidcService');
|
const oidcService = require('../services/oidcService');
|
||||||
|
|
||||||
// Enabling requires a complete config, or the login button would lead
|
// Validate the MERGED resulting state, not just the request: enabling
|
||||||
// straight to an error page.
|
// requires a complete config, and a partial PUT must not be able to
|
||||||
if (req.body.oidc_enabled === true) {
|
// blank the issuer/client while a stored enabled=true keeps a login
|
||||||
const current = await oidcService.getOidcConfig();
|
// 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 issuer = req.body.oidc_issuer_url ?? current.issuerUrl;
|
||||||
const clientId = req.body.oidc_client_id ?? current.clientId;
|
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)
|
const secretPresent = (typeof req.body.oidc_client_secret === 'string' && req.body.oidc_client_secret.length > 0)
|
||||||
|| Boolean(current.clientSecret);
|
|| Boolean(current.clientSecret);
|
||||||
if (!issuer || !clientId || !secretPresent) {
|
if (!issuer || !clientId || !secretPresent) {
|
||||||
return res.status(400).json({ error: 'Issuer URL, client ID and client secret must be configured before enabling SSO' });
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +535,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
|
// Mask sensitive secrets before sending to client
|
||||||
if (settingsObject.security_recaptcha_secret_key) {
|
if (settingsObject.security_recaptcha_secret_key) {
|
||||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||||
|
|||||||
@@ -900,9 +900,14 @@ router.get('/admin/sso/login', async (req, res) => {
|
|||||||
|
|
||||||
// IdP redirect target. Every failure lands back on the login page with a
|
// 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.
|
// 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) => {
|
router.get('/admin/sso/callback', async (req, res) => {
|
||||||
const oidcService = require('../services/oidcService');
|
const oidcService = require('../services/oidcService');
|
||||||
const fail = (key) => res.redirect(`/admin/login?sso_error=${key}`);
|
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];
|
const stashCookie = req.cookies?.[OIDC_STATE_COOKIE];
|
||||||
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
|
res.clearCookie(OIDC_STATE_COOKIE, { ...oidcStateCookieOptions(req), maxAge: undefined });
|
||||||
@@ -944,7 +949,7 @@ router.get('/admin/sso/callback', async (req, res) => {
|
|||||||
type: 'admin', id: admin.id, name: admin.username,
|
type: 'admin', id: admin.id, name: admin.username,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.redirect('/admin/dashboard');
|
return res.redirect(`${frontendBase}/admin/dashboard`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const codeMap = {
|
const codeMap = {
|
||||||
OIDC_NOT_CONFIGURED: 'config',
|
OIDC_NOT_CONFIGURED: 'config',
|
||||||
|
|||||||
@@ -131,7 +131,12 @@ function invalidateDiscoveryCache() {
|
|||||||
* callers surface that as a config error.
|
* callers surface that as a config error.
|
||||||
*/
|
*/
|
||||||
async function getClient(cfg) {
|
async function getClient(cfg) {
|
||||||
const key = `${cfg.issuerUrl}|${cfg.clientId}`;
|
// 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) {
|
if (_clientCache && _clientCache.key === key) {
|
||||||
return _clientCache;
|
return _clientCache;
|
||||||
}
|
}
|
||||||
@@ -151,13 +156,23 @@ async function getClient(cfg) {
|
|||||||
* base URL — nginx proxies /api to the backend, so this resolves publicly.
|
* base URL — nginx proxies /api to the backend, so this resolves publicly.
|
||||||
*/
|
*/
|
||||||
async function getRedirectUri() {
|
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 { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||||
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
|
const base = (await getFrontendBaseUrl()).replace(/\/$/, '');
|
||||||
if (!base) {
|
if (!base) {
|
||||||
// Without a public base URL the redirect_uri would be relative — the IdP
|
// 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
|
// would reject it with an opaque error on ITS side. Fail here with a
|
||||||
// clear config message instead.
|
// clear config message instead.
|
||||||
const err = new Error('FRONTEND_URL (or the general_site_url setting) must be set for SSO');
|
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';
|
err.code = 'OIDC_BAD_CONFIG';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -207,7 +222,7 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
|||||||
err.code = 'OIDC_NOT_CONFIGURED';
|
err.code = 'OIDC_NOT_CONFIGURED';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const { client } = await getClient(cfg);
|
const { client, issuerMetadata } = await getClient(cfg);
|
||||||
|
|
||||||
// Extract code/state from the callback URL, then exchange + validate the
|
// Extract code/state from the callback URL, then exchange + validate the
|
||||||
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
|
// ID token (issuer, audience, signature, exp, nonce, state — all enforced
|
||||||
@@ -220,16 +235,42 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
|||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
return tokenSet.claims();
|
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.
|
* Map validated ID token claims to an admin_users row.
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. external_subject === sub → that admin (must be active).
|
* 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
|
* 2. email match against an UNLINKED admin, only if email_verified === true
|
||||||
* → one-time link (stamps external_subject; auth_provider unchanged so
|
* → one-time link (stamps issuer+subject; auth_provider unchanged so
|
||||||
* a local password keeps working).
|
* a local password keeps working).
|
||||||
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
|
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
|
||||||
* claim; role = oidc_default_role; unusable random password).
|
* claim; role = oidc_default_role; unusable random password).
|
||||||
@@ -238,17 +279,21 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
|
|||||||
*/
|
*/
|
||||||
async function resolveAdminFromClaims(claims) {
|
async function resolveAdminFromClaims(claims) {
|
||||||
const sub = claims.sub;
|
const sub = claims.sub;
|
||||||
|
const iss = claims.iss;
|
||||||
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
|
const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : null;
|
||||||
const emailVerified = claims.email_verified === true;
|
const emailVerified = claims.email_verified === true;
|
||||||
|
|
||||||
if (!sub) {
|
if (!sub || !iss) {
|
||||||
const err = new Error('ID token has no sub claim');
|
const err = new Error('ID token has no sub/iss claim');
|
||||||
err.code = 'OIDC_BAD_CLAIMS';
|
err.code = 'OIDC_BAD_CLAIMS';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Established binding.
|
// 1. Established binding — issuer AND subject.
|
||||||
const bySub = await db('admin_users').where('external_subject', sub).first();
|
const bySub = await db('admin_users')
|
||||||
|
.where('external_issuer', iss)
|
||||||
|
.where('external_subject', sub)
|
||||||
|
.first();
|
||||||
if (bySub) {
|
if (bySub) {
|
||||||
if (!bySub.is_active) {
|
if (!bySub.is_active) {
|
||||||
const err = new Error('Admin account is deactivated');
|
const err = new Error('Admin account is deactivated');
|
||||||
@@ -259,8 +304,8 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. One-time email link — verified emails only, and only onto rows that
|
// 2. One-time email link — verified emails only, and only onto rows that
|
||||||
// have no binding yet (a different sub on the row means a different
|
// have no binding yet (a different identity on the row means a
|
||||||
// IdP identity already owns it).
|
// different IdP identity already owns it).
|
||||||
if (email && emailVerified) {
|
if (email && emailVerified) {
|
||||||
const byEmail = await db('admin_users')
|
const byEmail = await db('admin_users')
|
||||||
.where('email', email)
|
.where('email', email)
|
||||||
@@ -272,15 +317,35 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
err.code = 'OIDC_INACTIVE';
|
err.code = 'OIDC_INACTIVE';
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
await db('admin_users').where('id', byEmail.id).update({
|
// Claim atomically: two concurrent first-time callbacks with the same
|
||||||
external_subject: sub,
|
// email but DIFFERENT subjects must not both authenticate as this
|
||||||
updated_at: new Date(),
|
// 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', {
|
logger.info('OIDC: linked existing admin to IdP subject', {
|
||||||
adminId: byEmail.id,
|
adminId: byEmail.id,
|
||||||
sub,
|
sub,
|
||||||
});
|
});
|
||||||
return { ...byEmail, external_subject: sub };
|
return { ...byEmail, external_issuer: iss, external_subject: sub };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +382,7 @@ async function resolveAdminFromClaims(claims) {
|
|||||||
is_active: formatBoolean(true),
|
is_active: formatBoolean(true),
|
||||||
must_change_password: formatBoolean(false),
|
must_change_password: formatBoolean(false),
|
||||||
auth_provider: 'oidc',
|
auth_provider: 'oidc',
|
||||||
|
external_issuer: iss,
|
||||||
external_subject: sub,
|
external_subject: sub,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
@@ -343,7 +409,14 @@ async function saveOidcSettings(input) {
|
|||||||
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
|
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_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_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
|
||||||
if (input.oidc_scopes !== undefined) put('oidc_scopes', String(input.oidc_scopes).trim() || 'openid profile email', 'string');
|
if (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) {
|
if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) {
|
||||||
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
|
put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { setupService } from '../../services/setup.service';
|
|||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||||
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
|
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
|
||||||
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
export const AdminLoginPage: React.FC = () => {
|
export const AdminLoginPage: React.FC = () => {
|
||||||
@@ -366,7 +367,11 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
leftIcon={<KeyRound className="w-4 h-4" />}
|
leftIcon={<KeyRound className="w-4 h-4" />}
|
||||||
onClick={() => { window.location.href = '/api/auth/admin/sso/login'; }}
|
// 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')}
|
{settingsData.oidc_button_label?.trim() || t('adminLogin.ssoSignIn', 'Sign in with SSO')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user