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