Skip to content

Auth/PM-3813 - 2FA Management Endpoints - User Verification Refactor#7841

Draft
JaredSnider-Bitwarden wants to merge 23 commits into
mainfrom
auth/pm-38137/2fa-verification-refactor
Draft

Auth/PM-3813 - 2FA Management Endpoints - User Verification Refactor#7841
JaredSnider-Bitwarden wants to merge 23 commits into
mainfrom
auth/pm-38137/2fa-verification-refactor

Conversation

@JaredSnider-Bitwarden

@JaredSnider-Bitwarden JaredSnider-Bitwarden commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-38137

📔 Objective

To refactor our 2FA management endpoints to use our tokenable framework for ongoing user verification (previously proven by its adoption for authenticator). The GET endpoints require normal user verification via secret which then mints a user verification tokenable which can be presented to the PUT or DELETE endpoints for changing the data.

🎨 : Rename - Disable was changed to Delete everywhere applicable as Disable implies a soft delete and we actually hard delete all 2FA providers when they are "turned off"
🎨 : Rename - Aligned all models to use consistent naming convention.

API surface changes
  • New per-provider DELETE endpoints: DELETE /two-factor/{yubikey,duo,email,authenticator,webauthn/all} and DELETE /organizations/{id}/two-factor/duo.
  • Removed legacy endpoints : PUT /two-factor/disable and PUT /organizations/{id}/two-factor/disable, plus their [Obsolete] POST companions.
  • The new DELETE /two-factor/webauthn/all bulk endpoint exists for a specific reason: the per-credential DELETE /two-factor/webauthn refuses to remove the last registered credential (lockout-prevention rule in DeleteTwoFactorWebAuthnCredentialCommand). Without /all, a user with exactly one WebAuthn credential would have no way to disable WebAuthn entirely.
Behavior changes
  • Dropped the premium gate from GET /two-factor/yubikey and GET /two-factor/duo. The matching PUT endpoints still require premium. This lets lapsed-premium users read their own enrollment configuration and use the standard GET → DELETE flow to remove a provider they previously configured.
Test coverage
  • Expanded unit + integration tests across every per-provider GET / PUT / DELETE, including UV-token validator negative paths (expired, malformed, wrong-user, wrong-provider) and cross-provider replay rejection.

📸 Screenshots

See clients PR: bitwarden/clients#21385

Introduces a new tokenable bound to UserId + ProviderType for the upcoming
per-provider 2FA flow, alongside its factory, DI registration, and configurable
lifetime. Pure addition with no behavior change to existing flows.

- TwoFactorUserVerificationTokenable + ITwoFactorUserVerificationTokenableFactory + factory implementation
- Unit tests for tokenable and factory
- IGlobalSettings.TwoFactorUserVerificationTokenLifetimeInMinutes (default 30)
- DI registration for the data protector + tokenable factory
Per-provider disable request models for the upcoming DELETE endpoints, a
wrapper response model for the WebAuthn challenge, and a UserVerificationToken
field on the existing response models and PUT request models that will carry
the new replay token. Pure addition — no existing wire contracts narrow here.
…endpoints

Replace CheckAsync with three single-purpose helpers (ValidateUserBySecretAsync,
ValidateUserVerificationTokenAsync, ValidateUserHasPremiumAsync) plus a
MintProtectedUserVerificationToken helper. Each call site composes the guards
it needs explicitly.

Other changes in this refactor:
- New per-provider DELETE endpoints: DisableYubiKey, DisableDuo, DisableEmail,
  DisableOrganizationDuo
- GetWebAuthnChallenge returns the new TwoFactorWebAuthnChallengeResponseModel
  wrapper so a UV token can travel with the FIDO2 options
- DisableAuthenticator hardcodes TwoFactorProviderType.Authenticator
- GetYubiKey and GetDuo no longer gate on premium; lapsed-premium users can
  read their own configuration and use the standard GET -> DELETE flow
- Legacy PutDisable and PutOrganizationDisable endpoints removed; per-provider
  DELETEs replace them
- Inline Task.Delay calls dropped from rewritten methods; rate limiting belongs
  at the edge
- Unit test coverage extended: Goal-7 non-premium GET assertions, per-endpoint
  validator negative paths through PutDuo, organization NotFound branches for
  both PutOrganizationDuo and DisableOrganizationDuo, and the DisableOrganizationDuo
  happy path
The Authenticator PUT and DELETE request models inherited fields they no
longer read (Secret, MasterPasswordHash, Type). Rewrite both as standalone
classes carrying only the fields the controller actually uses. With those
two models no longer inheriting it, TwoFactorProviderRequestModel has no
remaining consumers and is removed.

- UpdateTwoFactorAuthenticatorRequestModel: standalone with Token, Key,
  UserVerificationToken
- TwoFactorAuthenticatorDisableRequestModel: standalone with UserVerificationToken,
  Key
- TwoFactorProviderRequestModel: deleted
- Integration tests updated to stop referencing the dropped fields
Drop the unused third parameter from the VerifySecretAsync signature and
inline the remaining conditional returns. Existing callers all pass the
two-argument form so no downstream changes are required; the existing
VerifySecretAsync_Works theory continues to cover password and OTP paths.
Adds end-to-end coverage for the GET, PUT, and DELETE paths on every 2FA
provider:

- Per-provider GET round-trip tests that mint via the controller and replay
  the resulting token against the matching DELETE (or PUT for the WebAuthn
  challenge endpoint), verifying the token round-trip survives DI + wire
  serialization
- Per-provider PUT happy paths exercising the token-replay chain end-to-end
- SendEmail invokes the email service when given a valid token
- DisableAuthenticator_BodyTypeMismatch_RespectsUrlRoute confirms the URL is
  the sole source of provider truth and any Type field in the body is dropped
  by deserialization
- DisableYubiKey_CrossProviderToken_BadRequest confirms the validator's
  provider-type binding rejects cross-provider replay
@JaredSnider-Bitwarden JaredSnider-Bitwarden changed the title Auth/pm 38137/2fa verification refactor Auth/PM-3813 - 2FA Management Endpoints - User Verification Refactor Jun 19, 2026
@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.93023% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.56%. Comparing base (2706c74) to head (915a5e8).
⚠️ Report is 18 commits behind head on main.

Files with missing lines Patch % Lines
.../Api/Auth/Models/Request/TwoFactorRequestModels.cs 84.61% 3 Missing and 1 partial ⚠️
src/Api/Auth/Controllers/TwoFactorController.cs 96.96% 2 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7841      +/-   ##
==========================================
+ Coverage   61.25%   61.56%   +0.30%     
==========================================
  Files        2193     2219      +26     
  Lines       97296    97856     +560     
  Branches     8767     8819      +52     
==========================================
+ Hits        59601    60247     +646     
+ Misses      35582    35460     -122     
- Partials     2113     2149      +36     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@JaredSnider-Bitwarden JaredSnider-Bitwarden added the ai-review Request a Claude code review label Jun 19, 2026
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the 2FA management endpoint refactor that moves per-provider GET → PUT/DELETE flows onto the tokenable user-verification framework. Focused on the new TwoFactorUserVerificationTokenable infrastructure, provider-type binding, the org-scoped Duo endpoints, the VerifySecretAsync signature change, and the premium-gate and DisableDelete renames. Cross-checked the new token against the established TwoFactorAuthenticatorUserVerificationTokenable pattern and confirmed test coverage for expiry, wrong-user, wrong-provider, and cross-provider replay paths.

Code Review Details

No blocking findings.

Notes from analysis (no action required):

  • The user-verification token binds UserId + ProviderType and is independently re-authorized against the path org in DeleteOrganizationDuo/PutOrganizationDuo via ManagePolicies, so the non-org-bound token does not enable cross-org abuse.
  • Removing the isSettingMFA bypass from VerifySecretAsync eliminates the prior unconditional return true path for passwordless users viewing MFA settings; every caller now performs real verification. This is a security improvement, and call sites plus VerifySecretAsync_Works were updated to the new signature.
  • The class summary on TwoFactorUserVerificationTokenable calls it "single-use," but like the existing authenticator tokenable it is replayable within its 30-minute lifetime — nothing enforces single use. Behavior matches the established pattern; only the wording is aspirational.
  • Premium-gate removal on GET /two-factor/yubikey and GET /two-factor/duo (PUT still gated) is intentional and documented in the PR description.
  • The two earlier review threads (removed brute-force Task.Delay, WebAuthn PUT/DELETE still requiring Secret) are both resolved.

Per-provider DELETE /two-factor/webauthn/all that mirrors the legacy
generic disable behavior for WebAuthn. The existing per-credential
DELETE /two-factor/webauthn refuses to remove the last registered
credential by design, so a bulk path is required for users who want
to disable WebAuthn entirely. Unit and integration coverage included.
…points

Mirror the five-test pattern (valid token, expired token, TryUnprotect
failure, token bound to different user, token bound to different provider)
across DisableYubiKey, DisableDuo, and DisableEmail. Brings the
non-Authenticator per-provider DELETE endpoints up to the same unit-test
shape recently added for DisableWebAuthnAll.
Comment thread src/Api/Auth/Controllers/TwoFactorController.cs
Per-provider 2FA endpoints reached via HTTP DELETE were named DisableX on
the controller. The underlying behavior is a hard removal of the provider
configuration, not a reversible disable, so the method and request-model
names now read as DeleteX to match what the code actually does.

Scope is limited to the controller surface and its request models. The
underlying service methods (IUserService / IOrganizationService) keep
their historical DisableTwoFactorProviderAsync names but gain XML doc
comments clarifying that they hard-delete the provider configuration.
Comment thread src/Api/Auth/Models/Request/TwoFactorRequestModels.cs
TwoFactorWebAuthnDeleteRequestModel (also the parent of
TwoFactorWebAuthnRequestModel for PUT) no longer inherits
SecretVerificationRequestModel, dropping the inherited secret-required
validation that was masking the token-only flow for
PUT /two-factor/webauthn and DELETE /two-factor/webauthn.
UserVerificationToken is now [Required] at the model layer, matching the
other per-provider request models.

Both WebAuthn integration tests now exercise the token-only path end-to-end.
UpdateTwoFactorDuoRequestModel and UpdateTwoFactorYubicoOtpRequestModel
no longer inherit SecretVerificationRequestModel. Each model is now
standalone with [Required] UserVerificationToken, matching the
per-provider request model shape used elsewhere in this controller.

Email integration tests for /send-email and PUT /email drop redundant
MasterPasswordHash payloads so they exercise the token-only flow cleanly.
The class was orphaned in cb1db26 (2025-09-02, PM-18179) when the
pm-17128-recovery-code-login feature-flag cleanup removed its sole
consumer (the PostRecover controller action and the matching
IUserService.RecoverTwoFactorAsync overload). No references remain
in either the server or clients repos.
TwoFactorEmailRequestModel (previously the shared body for the anonymous
login endpoint, the authenticated setup-send endpoint, and the
authenticated PUT setup endpoint via inheritance) splits into three
purpose-specific models:

- TwoFactorEmailLoginRequestModel  (login flow, secret-based)
- TwoFactorEmailSetupRequestModel  (setup-send, token-only)
- UpdateTwoFactorEmailRequestModel (setup-update, inherits the setup-send
                                    shape and adds the OTP)

The setup pair carry the token-based authentication shape and share the
ToUser mutation; the login model keeps only the credentials its endpoint
consumes. Setup models gain [Required] enforcement on Email and
UserVerificationToken at the model layer.

Unit and integration tests added for SendEmail, PutEmail, and GetEmail
(mirroring the DeleteEmail patterns), plus the [Required] regression
guards on the setup wire shapes, plus a master-password happy-path and
validator regression guard for the login endpoint.
The previous summary opened with "Single-use proof", which contradicts
the immediately following clause about replay within the token's
lifetime. Reworded as "Time-limited proof" so the two clauses align.
ReadJsonRootAsync now owns the JsonDocument lifetime via a using
declaration, returning the cloned root element. Callers no longer need
to track the document to dispose it.
… DeleteWebAuthn

Adds the 5-test user-verification token validation matrix that already
exists for the DeleteEmail / DeleteAuthenticator / etc. actions:
expired token, TryUnprotect fail, cross-user binding, cross-provider
binding, and a valid-token happy path. Each happy path also verifies
the appropriate downstream command or service invocation.
…ections

Reorders the existing tests into contiguous provider blocks mirroring the
integration test file's layout, with banner comments delimiting each
section: controller-helper tests, Authenticator, YubiKey, Duo,
Organization Duo, WebAuthn, Email, and private helpers. No tests or
helper bodies change.
Documents the per-provider GET → PUT/DELETE flow, the
TwoFactorUserVerificationTokenable's bindings and lifetime, the
validation rules ValidateUserVerificationTokenAsync enforces, and why
Authenticator continues to use its own Key-bound tokenable. Structured
so additional 2FA aspects can be added as sibling sections.
Aligns every 2FA request-model class to the file-wide TwoFactor<Provider>
prefix already used by the Delete family:

  UpdateTwoFactorAuthenticatorRequestModel -> TwoFactorAuthenticatorUpdateRequestModel
  UpdateTwoFactorDuoRequestModel           -> TwoFactorDuoUpdateRequestModel
  UpdateTwoFactorYubicoOtpRequestModel     -> TwoFactorYubiKeyUpdateRequestModel
  UpdateTwoFactorEmailRequestModel         -> TwoFactorEmailUpdateRequestModel
  TwoFactorWebAuthnRequestModel            -> TwoFactorWebAuthnUpdateRequestModel

The YubiKey rename also aligns the class name with the TwoFactorProviderType
enum value (YubiKey, not YubicoOtp). No HTTP route or wire-shape changes.
…nces

Earlier work renamed the user-validation helper to ValidateUserBySecretAsync
and inlined the per-action organization access check, but the unit tests
still carried the old names:

  CheckAsync_*               -> ValidateUserBySecretAsync_*
  CheckOrganizationAsync_*   -> GetOrganizationDuo_*  (and moved into the
                                Organization Duo section, alongside the
                                matching Put/Delete tests)
  SetupCheckOrganizationAsyncToPass -> SetupOrganizationAccessToPass

The CheckOrganizationAsync helper no longer exists; the two tests that
referenced it are GetOrganizationDuo tests exercising the same
ManagePolicies / GetByIdAsync paths that the Put and Delete variants
already cover.
- Mark the 3 GetUserTwoFactorXProvidersJson / GetOrganizationTwoFactorDuoProvidersJson
  helpers and SetupOrganizationAccessToPass as `static` to match the
  rest of the helper methods in the file.
- Generalize the comment in SetupGetUserByPrincipalAsync — every action
  that calls model.ToUser(user) needs TwoFactorProviders cleared, not
  just PutAuthenticator.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant