diff --git a/backend/__tests__/integration/helpers/mockOidcProvider.js b/backend/__tests__/integration/helpers/mockOidcProvider.js index f50b5374..9c83ce94 100644 --- a/backend/__tests__/integration/helpers/mockOidcProvider.js +++ b/backend/__tests__/integration/helpers/mockOidcProvider.js @@ -33,6 +33,8 @@ class MockOidcProvider { this.nextUser = { sub: 'user-1', email: 'sso@example.com', 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' }); } } diff --git a/backend/__tests__/integration/oidcSso.test.js b/backend/__tests__/integration/oidcSso.test.js index 7524a6b7..a1b6e0b7 100644 --- a/backend/__tests__/integration/oidcSso.test.js +++ b/backend/__tests__/integration/oidcSso.test.js @@ -99,7 +99,7 @@ describe('OIDC SSO (#798)', () => { idp.setNextUser({ sub: 'sub-jit-1', email: 'jit@example.com', 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: 'renamed@example.com', 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: 'renamed@example.com' }).first()).toBeFalsy(); @@ -145,7 +145,7 @@ describe('OIDC SSO (#798)', () => { idp.setNextUser({ sub: 'sub-local-1', email: 'local@example.com', 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: 'renamed@example.com', 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: 'nonce@example.com', 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: 'nonce@example.com' }).first()).toBeFalsy(); }); @@ -207,7 +207,7 @@ describe('OIDC SSO (#798)', () => { await oidcService.saveOidcSettings({ oidc_autoprovision: false }); idp.setNextUser({ sub: 'sub-new-user', email: 'new@example.com', 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: 'new@example.com' }).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: 'userinfo@example.com', 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: 'userinfo@example.com' }).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: 'colliding@example.com', 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: 'colliding@example.com' }).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, + }); + } + }); }); diff --git a/backend/migrations/core/162_admin_oidc_identity.js b/backend/migrations/core/162_admin_oidc_identity.js index 41a16a26..89bb0898 100644 --- a/backend/migrations/core/162_admin_oidc_identity.js +++ b/backend/migrations/core/162_admin_oidc_identity.js @@ -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 diff --git a/backend/src/middleware/maintenance.js b/backend/src/middleware/maintenance.js index bb1493f3..5f992db8 100644 --- a/backend/src/middleware/maintenance.js +++ b/backend/src/middleware/maintenance.js @@ -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' diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 161245c3..eac0d152 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -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 = '••••••••'; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1318bbe0..32299f81 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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', diff --git a/backend/src/services/oidcService.js b/backend/src/services/oidcService.js index bac4651a..ccf90288 100644 --- a/backend/src/services/oidcService.js +++ b/backend/src/services/oidcService.js @@ -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'); } diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx index e5010d0a..4a5f3fda 100644 --- a/frontend/src/pages/admin/AdminLoginPage.tsx +++ b/frontend/src/pages/admin/AdminLoginPage.tsx @@ -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={} - 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')}