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 };
|
||||
// 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;
|
||||
}
|
||||
@@ -81,6 +83,7 @@ class MockOidcProvider {
|
||||
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'],
|
||||
@@ -128,13 +131,18 @@ class MockOidcProvider {
|
||||
}
|
||||
|
||||
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,
|
||||
extraClaims: idTokenClaims,
|
||||
});
|
||||
const accessToken = crypto.randomBytes(16).toString('base64url');
|
||||
this.accessTokens.set(accessToken, stored.user);
|
||||
return json(200, {
|
||||
access_token: crypto.randomBytes(16).toString('base64url'),
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 300,
|
||||
id_token: idToken,
|
||||
@@ -143,6 +151,13 @@ class MockOidcProvider {
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('OIDC SSO (#798)', () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
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='));
|
||||
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 () => {
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
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.
|
||||
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 });
|
||||
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();
|
||||
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 });
|
||||
idp.setNextUser({ sub: 'sub-jit-1', email: '[email protected]', email_verified: true });
|
||||
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 });
|
||||
});
|
||||
|
||||
it('rejects a callback without the state cookie', async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
@@ -199,7 +199,7 @@ describe('OIDC SSO (#798)', () => {
|
||||
idp.setNextUser({ sub: 'sub-nonce', email: '[email protected]', email_verified: true });
|
||||
const res = await ssoRoundTrip();
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('OIDC SSO (#798)', () => {
|
||||
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('/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();
|
||||
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 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
|
||||
* 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 (auth_provider, external_subject),
|
||||
* NEVER on email alone — email-matching is an
|
||||
* account-takeover vector with IdPs that don't verify
|
||||
* addresses. Nullable: local accounts have none.
|
||||
* 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 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).
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
@@ -18,18 +25,23 @@ exports.up = async function up(knex) {
|
||||
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(['auth_provider', 'external_subject'], {
|
||||
indexName: 'admin_users_provider_subject_unique',
|
||||
t.unique(['external_issuer', 'external_subject'], {
|
||||
indexName: 'admin_users_issuer_subject_unique',
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
if (await knex.schema.hasColumn('admin_users', col)) {
|
||||
// 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).
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -366,7 +367,11 @@ export const AdminLoginPage: React.FC = () => {
|
||||
size="lg"
|
||||
className="w-full"
|
||||
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')}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user