fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID)#1142
fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID)#1142netscout2001 wants to merge 10 commits intomaziggy:devfrom
auto_link_existing_accounts with custom email claims (Azure Entra ID)#1142Conversation
Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest report informationally until their drift is caught up. ja notably has 27 real placeholder bugs worth fixing before promotion to strict.
Relax the SEC-1 guard so that only Fall B (email_claim='email' + require_email_verified=False) is blocked for auto_link. Fall C (custom claim e.g. preferred_username, upn) never performs an email_verified check and is therefore safe to use with auto_link, enabling Azure Entra ID configurations. - Update CheckConstraint formula in OIDCProvider model - Update schema validators in OIDCProviderCreate and OIDCProviderUpdate - Update _enforce_auto_link_safety() route guard in mfa.py - Add _migrate_update_auto_link_constraint() for SQLite table recreation and PostgreSQL DROP/ADD CONSTRAINT to update existing installations - Narrow backfill SQL to only reset unsafe Fall B rows - Update integration and unit tests to reflect new guard semantics
Add w-full to the auto-link and require-email-verified labels so they always occupy their own row regardless of description text length. Previously, when auto-link was enabled the shorter description caused the require-email-verified element to wrap next to auto-link.
maziggy
left a comment
There was a problem hiding this comment.
Security model — sound, but the precondition needs to be documented
The relaxation is correct for the stated case. SEC-1's attack vector is "IdP doesn't verify email but Bambuddy auto-links anyway" — that path only opens when
email_claim='email' because that's the only branch that consults email_verified. _resolve_provider_email Fall C at mfa.py:432 already skips the verification step entirely
for custom claims, so the constraint relaxation removes a guard that was protecting nothing in that branch.
But the safety claim ("Fall C is safe") quietly assumes the custom claim is tenant-administered — Azure Entra ID upn / preferred_username on a corporate tenant, where the admin sets the value and users can't change it. For an IdP that lets end users self-assert preferred_username (some Keycloak setups, custom OIDC servers, certain Authentik realm configurations), an attacker can register an account with preferred_username=alice@example.com and auto-link to a local Alice's account. That's the same SEC-1 takeover the original guard was designed to prevent — just via a different claim.
The PR description checks "No docs update required". That's not quite accurate — this is a security-model relaxation with a precondition admins need to know about. Could you
add a short note in the OIDC settings docs / wiki page along the lines of:
▎ Custom email claims are safe to auto-link when the claim is tenant-administered (e.g. Azure Entra ID upn / preferred_username). If your IdP lets end users self-assert this
▎ claim's value, do not enable Auto-link.
Bambuddy's convention is that security-affecting changes ship with corresponding doc updates in the same PR.
Code correctness — five enforcement layers, all consistent
Confirmed all five sites enforce the same condition (block iff auto_link AND email_claim='email' AND NOT require_email_verified):
- models/oidc_provider.py CheckConstraint
- schemas/auth.py OIDCProviderCreate.check_auto_link_requires_verified
- schemas/auth.py OIDCProviderUpdate.check_auto_link_requires_verified (pessimistic when email_claim is None — comment correctly defers the existing-Fall-C-with-partial-update case to the route guard, which sees the merged state)
- api/routes/mfa.py _enforce_auto_link_safety (runs after the setattr loop)
- DB CHECK constraint string
The unit tests pin the SQL string directly — good defence against the four staying in sync as the code evolves.
Migration — idempotent, one minor nit
- PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT via _safe_execute. Fully idempotent.
- SQLite: detects old formula via sqlite_master, table-recreate inside begin_nested for atomicity, no-op when already on the new formula. The pre-SEC-1 case (no constraint
at all) is documented as "skipped — app-level guards suffice", which is fine. - The migration runs after the existing SEC-1 setup which now uses the new formula — on a fresh PG install you ADD the constraint, then DROP + ADD the same constraint.
Wasteful but idempotent.
Nit: the SQLite recreate path doesn't SELECT count(*) before DROP TABLE oidc_providers to verify the copy landed. Low risk because of the savepoint, but a one-line guard wouldn't hurt. Not blocking.
Tests — cover the relaxation but missing one
The schema, guard, constraint, and SQLite migration layers all have coverage. What's missing: an end-to-end OIDC callback test confirming that a user with claims =
{"preferred_username": "alice@example.com"} actually links to a local alice@example.com account when auto_link=True + email_claim='preferred_username' is configured. Without that, a regression in _resolve_provider_email's Fall C path wouldn't be caught — the existing tests pin the configuration layer, not the actual link behaviour the linked issue (#1088) is asking for.
Minor
- OIDCProviderSettings.tsx adds w-full to two labels. Unrelated to the auto_link change. Either drop the change or note in the description what UX issue it's fixing.
- No CHANGELOG entry — Bambuddy's convention is to add one for any user-visible / config-affecting change. This one needs a brief ### Fixed entry under the unreleased block, mentioning the linked issue (#1088) and that custom email claims are now allowed for auto-link.
- Add row-count guard before DROP TABLE in SQLite auto_link constraint migration to verify the copy completed successfully - Add E2E integration test confirming Fall C (preferred_username) auto-link actually links an existing local user via the OIDC callback flow - Add in-app security warning when auto_link is enabled with a custom email claim, noting that the claim must be tenant-administered - Add emailClaimCustomClaimAutoLinkWarning i18n key to all 8 locale files - Add CHANGELOG entries for the auto_link relaxation and layout fix
|
Thanks for the detailed review! I've addressed all four points: 1. Security note for custom email claims
Added an inline warning in the OIDC settings form that appears whenever
The warning is rendered in amber in the UI and is localised across all 8 locale files. New i18n key: 2. SQLite count guard before
|
Description
Relaxes the SEC-1 guard for
auto_link_existing_accountsso that providers using a customemail_claim(e.g.preferred_username,upn) — the recommended Azure Entra ID configuration — can safely enable automatic account linking.Previously the guard blocked all combinations except
email_claim='email'+require_email_verified=True. This also blocked Azure Entra ID configurations that use a custom claim, even though those are not subject to the email-verification bypass attack.The new guard only blocks the genuinely unsafe combination (Fall B):
email_claim='email'+require_email_verified=False. Custom-claim paths (Fall C) never perform anemail_verifiedcheck, so an attacker-controlled IdP cannot exploit email matching — auto-linking is safe there.Docs ->
Related Issue
Fixes #1088
Documentation
Type of Change
Changes Made
models/oidc_provider.py: UpdatedCheckConstraintformula fromrequire_email_verified = TRUE AND email_claim = 'email'toemail_claim != 'email' OR require_email_verified = TRUE(only blocks Fall B)schemas/auth.py: UpdatedOIDCProviderCreateandOIDCProviderUpdatemodel validators and error message to match the new guard conditionapi/routes/mfa.py: Updated_enforce_auto_link_safety()combined-state guard to the same Fall-B-only conditioncore/database.py: Added_migrate_update_auto_link_constraint()to update the DB constraint on existing installations — SQLite via table recreation (shadow table pattern), PostgreSQL viaDROP CONSTRAINT IF EXISTS+ADD CONSTRAINT; narrowed backfill SQL to only reset unsafe Fall B rowstests/integration/test_mfa_api.py: Updated 4 tests and added 2 new tests covering the newly allowed Fall C scenarios and continued blocking of Fall Btests/unit/test_db_dialect.py: Updated 2 unit tests and added 4 new tests for the new constraint formula and the SQLite migration functionTesting
163 relevant tests pass (
tests/integration/test_mfa_api.py+tests/unit/test_db_dialect.py). Full test suite: no regressions.Key scenarios verified:
emailTrueTrueemailFalseTruepreferred_usernameTrueupnFalseTrueAdditional Notes
Migration behavior:
CHECKconstraint is applied at schema creation time — no action needed._migrate_update_auto_link_constraint()recreates theoidc_providerstable with the new constraint (SQLite does not supportALTER TABLE DROP/ADD CONSTRAINT). The old constraint is detected viasqlite_master; the migration is a no-op if already on the new formula.DROP CONSTRAINT IF EXISTS+ADD CONSTRAINT— fully idempotent.email_claim='email'+require_email_verified=False+auto_link=True) are reset by the backfill; Fall C rows are left unchanged.