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
@@ -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' });
}
}
+65 -8
View File
@@ -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
+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'
+30 -4
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) {
// 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',
+88 -15
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({
// 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');
}
+6 -1
View File
@@ -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>