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
+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>