feat(auth): per-instance opt-out for the email_verified strict check (#115) #117

Merged
james merged 1 commit from 115-oidc-email-verified-optional into main 2026-06-18 14:15:01 +00:00
Owner

Summary

OIDC_<NAME>_REQUIRE_EMAIL_VERIFIED=false disables the strict emailVerified === true check for one OIDC instance. Default is true (current behaviour). Only the literal string false opts out"False", "no", "0", "off", empty, and unset all stay on the secure default. Anti-typo defence so a misconfiguration can't silently weaken the check.

Intended for self-hosters with complete control over their IdP's user population — admin-curated Authentik for a known team, etc. Re-opens the account-takeover-via-unverified-email vector from ADR-0015 §3 on instances with self-service registration or social-login bridges, so the security trade-off is documented prominently in both ADR-0017 §4.6 and the self-hoster guide.

What stays enforced regardless of the flag

  • id_token signature, iss / aud / exp / nbf, nonce binding, OIDC §5.3.2 userinfo sub-match. All cryptographic / structural anchors unchanged.
  • The email claim itself — Carol's linking decision tree needs it, with or without the verified-flag policy.
  • The email_in_use / identity_belongs_to_other refusals from ADR-0015 §3 still fire.

Visibility

The flag emits a single loud console.warn the first time the instance is built per process:

[oidc:<name>] email_verified check is DISABLED via OIDC_<NAME>_REQUIRE_EMAIL_VERIFIED=false.
An attacker with the ability to register accounts in this IdP can take over Carol accounts
with the same email. See ADR-0017 §4.6.

A module-level Set<string> tracks which instances we've shouted about; parseConfigs runs on every getEnabledProviders() call so a naive per-call warn would spam container logs. Reset hook (_clearWarnedInstancesForTests) for the test surface.

Userinfo fallback tightening

Bonus: the existing userinfo fallback now only round-trips for email_verified when the instance is configured to check it AND the id_token didn't carry it. Saves one request per sign-in in the opt-out case.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean.
  • npm test — 281 / 50 skipped (was 273 / 50; 8 new tests).
    • tests/auth/oidc-providers.test.ts (+4): warn fires on literal "false"; deduplicates across calls; typo values ("False", "no", "0", "off", "") do NOT warn; default (unset) does not warn.
    • tests/api/oauth.test.ts (+4): id_token email_verified: false + opt-out → session; id_token without claim + opt-out → session (no userinfo round-trip needed); STILL rejects when email itself is missing even with opt-out; "False" (capital F) leaves the strict check on and rejects.
  • Manual probe on carol.int.wynning.tech once released — set OIDC_AUTHENTIK_REQUIRE_EMAIL_VERIFIED=false, observe the warn in startup logs, sign in as a user whose email_verified is false.

Files

  • lib/auth/oidc-providers.ts — env parsing + InstanceConfig.requireEmailVerified + warn-once + conditional check.
  • docs/adr/0017-oidc-generic-provider.md — new §4.6.
  • docs/oidc-self-hoster-guide.md — new "Foot-gun" section with the threat model up front.
  • tests/auth/oidc-providers.test.ts, tests/api/oauth.test.ts — new scenarios.

Closes #115.

🤖 Generated with Claude Code

## Summary `OIDC_<NAME>_REQUIRE_EMAIL_VERIFIED=false` disables the strict `emailVerified === true` check for one OIDC instance. Default is `true` (current behaviour). **Only the literal string `false` opts out** — `"False"`, `"no"`, `"0"`, `"off"`, empty, and unset all stay on the secure default. Anti-typo defence so a misconfiguration can't silently weaken the check. Intended for self-hosters with **complete control over their IdP's user population** — admin-curated Authentik for a known team, etc. Re-opens the account-takeover-via-unverified-email vector from ADR-0015 §3 on instances with self-service registration or social-login bridges, so the security trade-off is documented prominently in both ADR-0017 §4.6 and the self-hoster guide. ## What stays enforced regardless of the flag - id_token signature, iss / aud / exp / nbf, nonce binding, OIDC §5.3.2 userinfo sub-match. **All cryptographic / structural anchors unchanged.** - The `email` claim itself — Carol's linking decision tree needs it, with or without the verified-flag policy. - The `email_in_use` / `identity_belongs_to_other` refusals from ADR-0015 §3 still fire. ## Visibility The flag emits a single loud `console.warn` the first time the instance is built per process: ``` [oidc:<name>] email_verified check is DISABLED via OIDC_<NAME>_REQUIRE_EMAIL_VERIFIED=false. An attacker with the ability to register accounts in this IdP can take over Carol accounts with the same email. See ADR-0017 §4.6. ``` A module-level `Set<string>` tracks which instances we've shouted about; `parseConfigs` runs on every `getEnabledProviders()` call so a naive per-call warn would spam container logs. Reset hook (`_clearWarnedInstancesForTests`) for the test surface. ## Userinfo fallback tightening Bonus: the existing userinfo fallback now only round-trips for `email_verified` when the instance is configured to check it AND the id_token didn't carry it. Saves one request per sign-in in the opt-out case. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean. - [x] `npm test` — 281 / 50 skipped (was 273 / 50; 8 new tests). - `tests/auth/oidc-providers.test.ts` (+4): warn fires on literal `"false"`; deduplicates across calls; typo values (`"False"`, `"no"`, `"0"`, `"off"`, `""`) do NOT warn; default (unset) does not warn. - `tests/api/oauth.test.ts` (+4): id_token `email_verified: false` + opt-out → session; id_token without claim + opt-out → session (no userinfo round-trip needed); STILL rejects when `email` itself is missing even with opt-out; `"False"` (capital F) leaves the strict check on and rejects. - [ ] Manual probe on `carol.int.wynning.tech` once released — set `OIDC_AUTHENTIK_REQUIRE_EMAIL_VERIFIED=false`, observe the warn in startup logs, sign in as a user whose `email_verified` is false. ## Files - `lib/auth/oidc-providers.ts` — env parsing + `InstanceConfig.requireEmailVerified` + warn-once + conditional check. - `docs/adr/0017-oidc-generic-provider.md` — new §4.6. - `docs/oidc-self-hoster-guide.md` — new "Foot-gun" section with the threat model up front. - `tests/auth/oidc-providers.test.ts`, `tests/api/oauth.test.ts` — new scenarios. Closes #115. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): per-instance opt-out for the email_verified strict check
All checks were successful
Commits / Conventional Commits (pull_request) Successful in 6s
PR / OSV-Scanner (pull_request) Successful in 32s
PR / Static analysis (pull_request) Successful in 33s
PR / npm audit (pull_request) Successful in 56s
PR / Lint (pull_request) Successful in 1m1s
PR / Typecheck (pull_request) Successful in 1m3s
Secrets / gitleaks (pull_request) Successful in 32s
PR / Test (postgres) (pull_request) Successful in 1m14s
PR / Test (sqlite) (pull_request) Successful in 1m21s
PR / Coverage (soft) (pull_request) Successful in 1m15s
PR / Trivy (image) (pull_request) Successful in 1m27s
PR / Build (pull_request) Successful in 1m38s
a8167cc639
OIDC_<NAME>_REQUIRE_EMAIL_VERIFIED=false disables the strict
`emailVerified === true` check for one OIDC instance. Default is
`true` (current behaviour); only the literal string "false" opts
out — typo values like "False", "no", "0", "off" stay on the
secure default. Anti-typo defence so a misconfiguration doesn't
silently weaken the check.

Intended for self-hosters with complete control over their IdP's
user population (admin-curated Authentik for a known team, etc.).
Re-opens the account-takeover-via-unverified-email vector from
ADR-0015 §3 on instances where the IdP has self-service registration
or social-login bridges — see ADR-0017 §4.6 for the threat model
and `docs/oidc-self-hoster-guide.md` for the foot-gun walkthrough.

The flag emits a single loud console.warn the first time the
instance is built per process — surfaces in container logs without
grep. Per-instance only; no global opt-out (per-IdP trust is a
per-IdP statement).

Carol still requires:
- The id_token signature, iss/aud/exp/nbf, nonce binding,
  userinfo sub-match (unchanged trust anchors).
- The `email` claim itself, regardless of the verified-flag policy
  (linking decision tree needs it; refusal paths from ADR-0015 §3
  stay enforced).

Userinfo fallback path tightened: we only round-trip to userinfo
for `email_verified` when the instance is configured to check it
AND the id_token didn't carry it. Saves a request in the opt-out
case.

Closes #115.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

📊 Test coverage

Patch coverage: 100.0% (24/24 added lines) (soft target ≥ 80%)

Overall (app/, lib/, db/, excluding UI per ADR-0019):

Metric Value Soft target
Lines 84.0% ≥ 50%
Branches 82.6% ≥ 75%
Functions 90.8% informational

Changed files in this PR (source only — tests excluded):

File Patch coverage Overall lines Branches
lib/auth/oidc-providers.ts 100.0% (24/24) 93.3% 91.2%

Soft thresholds per ADR-0019. Coverage is informational and does not block merge.

<!-- coverage-comment --> ## 📊 Test coverage **Patch coverage:** 100.0% (24/24 added lines) ✅ *(soft target ≥ 80%)* **Overall** (`app/`, `lib/`, `db/`, excluding UI per ADR-0019): | Metric | Value | Soft target | |---|---|---| | Lines | 84.0% ✅ | ≥ 50% | | Branches | 82.6% ✅ | ≥ 75% | | Functions | 90.8% | informational | **Changed files in this PR** (source only — tests excluded): | File | Patch coverage | Overall lines | Branches | |---|---|---|---| | `lib/auth/oidc-providers.ts` | 100.0% (24/24) | 93.3% | 91.2% | Soft thresholds per [ADR-0019](docs/adr/0019-coverage-soft-targets.md). Coverage is informational and does not block merge.
james merged commit bcf1f92808 into main 2026-06-18 14:15:01 +00:00
james deleted branch 115-oidc-email-verified-optional 2026-06-18 14:15:02 +00:00
Sign in to join this conversation.
No description provided.