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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user