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:
@@ -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'
|
||||
|
||||
@@ -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 = '••••••••';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user