feat(auth): OIDC SSO for admin users — phase 1 (#798)

Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.

Backend:
- migration 162: admin_users.auth_provider ('local' default) +
  external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
  rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
  cached discovery, sub-based identity binding — email linking of
  existing admins only with email_verified=true; JIT behind
  oidc_autoprovision with configurable default role and an unusable
  random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
  cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
  the callback reuses the local login's session establishment
  (completeAdminLogin split into establishAdminSession + JSON wrapper)
  so SSO sessions are identical downstream; every failure lands on
  /admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
  write-only, redacted to a set-flag; registered ABOVE the generic
  /:type matcher which would shadow them); oidc_client_secret added to
  the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
  login page

Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
  autoprovision + default role, button label, enable toggle, redirect
  URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
  param surfaced as translated toasts; EN+DE i18n

Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.

MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
This commit is contained in:
Paul Nothaft
2026-07-16 08:54:26 +02:00
parent f0cdcddb92
commit ed5fc5ad5c
17 changed files with 1507 additions and 18 deletions
@@ -0,0 +1,39 @@
/**
* Migration 162: OIDC identity binding for admin users (#798).
*
* - `auth_provider` — 'local' (default) or 'oidc'. Which authority owns the
* account's credentials.
* - `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.
*
* Composite unique index so one IdP subject can't map to two admin rows.
* Additive + guarded; existing rows keep working untouched ('local', NULL).
*/
exports.up = async function up(knex) {
if (!(await knex.schema.hasColumn('admin_users', 'auth_provider'))) {
await knex.schema.alterTable('admin_users', (t) => {
t.string('auth_provider', 20).notNullable().defaultTo('local');
});
}
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',
});
});
}
};
exports.down = async function down(knex) {
for (const col of ['external_subject', '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
await knex.schema.alterTable('admin_users', (t) => t.dropColumn(col));
}
}
};