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:
Paul Nothaft
2026-07-16 09:41:48 +02:00
parent ac1838fbd7
commit 7f7d38a57f
8 changed files with 242 additions and 44 deletions
+5
View File
@@ -73,6 +73,11 @@ async function maintenanceMiddleware(req, res, next) {
// entries here matched nothing, which is exactly why the lockout happened).
const skipPaths = [
'/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/public/settings',
'/health'
+31 -5
View File
@@ -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
if (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');
// Enabling requires a complete config, or the login button would lead
// straight to an error page.
if (req.body.oidc_enabled === true) {
const current = await oidcService.getOidcConfig();
// 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 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
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
+7 -2
View File
@@ -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
// 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 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];
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,
});
return res.redirect('/admin/dashboard');
return res.redirect(`${frontendBase}/admin/dashboard`);
} catch (error) {
const codeMap = {
OIDC_NOT_CONFIGURED: 'config',
+91 -18
View File
@@ -131,7 +131,12 @@ function invalidateDiscoveryCache() {
* callers surface that as a config error.
*/
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) {
return _clientCache;
}
@@ -151,13 +156,23 @@ async function getClient(cfg) {
* 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('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';
throw err;
}
@@ -207,7 +222,7 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
err.code = 'OIDC_NOT_CONFIGURED';
throw err;
}
const { client } = await getClient(cfg);
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
@@ -220,16 +235,42 @@ async function handleCallback(currentUrl, { state, nonce, 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.
*
* 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
* → 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).
* 3. JIT provisioning when oidc_autoprovision is on (requires an email
* claim; role = oidc_default_role; unusable random password).
@@ -238,17 +279,21 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) {
*/
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) {
const err = new Error('ID token has no sub claim');
if (!sub || !iss) {
const err = new Error('ID token has no sub/iss claim');
err.code = 'OIDC_BAD_CLAIMS';
throw err;
}
// 1. Established binding.
const bySub = await db('admin_users').where('external_subject', sub).first();
// 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');
@@ -259,8 +304,8 @@ async function resolveAdminFromClaims(claims) {
}
// 2. One-time email link — verified emails only, and only onto rows that
// have no binding yet (a different sub on the row means a different
// IdP identity already owns it).
// 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)
@@ -272,15 +317,35 @@ async function resolveAdminFromClaims(claims) {
err.code = 'OIDC_INACTIVE';
throw err;
}
await db('admin_users').where('id', byEmail.id).update({
external_subject: sub,
updated_at: new Date(),
});
// 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_subject: sub };
return { ...byEmail, external_issuer: iss, external_subject: sub };
}
}
@@ -317,6 +382,7 @@ async function resolveAdminFromClaims(claims) {
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(),
@@ -343,7 +409,14 @@ async function saveOidcSettings(input) {
if (input.oidc_autoprovision !== undefined) put('oidc_autoprovision', input.oidc_autoprovision === true, 'boolean');
if (input.oidc_default_role !== undefined) put('oidc_default_role', String(input.oidc_default_role).trim(), 'string');
if (input.oidc_button_label !== undefined) put('oidc_button_label', String(input.oidc_button_label).trim(), 'string');
if (input.oidc_scopes !== undefined) put('oidc_scopes', String(input.oidc_scopes).trim() || 'openid profile email', 'string');
if (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');
}