Skip to content

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

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

Auth/PM-3813 - 2FA Management Endpoints - User Verification Refactor#7841
JaredSnider-Bitwarden wants to merge 48 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
Clients PR: bitwarden/clients#21385

📔 Objective

Refactor the 2FA management endpoints to use our tokenable framework for ongoing user verification — a pattern already established by the Authenticator provider. Each GET /two-factor/<provider> mints a short-lived user-verification token bound to (UserId, ProviderType); subsequent management calls (PUT, DELETE, and chained interior steps) replay that same token instead of re-collecting a secret. One secret prompt per management session, consistent shape across providers, single tokenable type to maintain.

🎨 Rename — Disable → Delete. "Disable" implied a reversible soft-disable, but the underlying behavior hard-deletes the provider's MetaData (TOTP shared secret, Duo client secret, YubiKey IDs, registered WebAuthn credentials, etc.). Renamed at the controller layer; service-layer names left alone to keep scope tight.

🎨 Rename — Model naming. Every 2FA request model now follows TwoFactor<Provider><Verb>RequestModel (e.g., TwoFactorWebAuthnUpdateRequestModel, TwoFactorDuoDeleteRequestModel). Provider first to match the TwoFactorProviderType enum.

API surface changes
  • New per-provider DELETE endpoints: DELETE /two-factor/{yubikey,duo,email,authenticator,webauthn,webauthn/all} and DELETE /organizations/{id}/two-factor/duo. All return 204 No Content except per-credential DELETE /two-factor/webauthn (which shrinks the credential list rather than destroying the provider, so it still carries the updated parent state).
  • Removed legacy endpoints: PUT /two-factor/disable and PUT /organizations/{id}/two-factor/disable, plus their [Obsolete] POST companions.
  • New DELETE /two-factor/webauthn/all bulk endpoint. 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.
  • POST /two-factor/get-webauthn-challenge switched from re-collecting the secret to replaying the UV token minted by get-webauthn. The challenge step doesn't mint a new token — the original stays valid through the subsequent PUT /two-factor/webauthn.
  • Email request models split by flow: TwoFactorEmailLoginRequestModel (login challenge), TwoFactorEmailSetupRequestModel (setup-send), TwoFactorEmailUpdateRequestModel (PUT). Email is the only provider with three distinct flows.
Architecture
  • Per-action response shapes. GET, PUT, and DELETE each have their own purpose-built response model (TwoFactor<Provider><Action>ResponseModel), with the field surface delegated to shared TwoFactor<Provider>Details DTOs that read provider state once. Replaces the prior pattern of one response model serving every action.
  • Chain endpoints. Two endpoints sit between GET (mint) and PUT (replay) rather than fitting cleanly into either bucket:
    • POST /two-factor/get-webauthn-challenge — replays the GET's token, returns FIDO2 registration options the PUT needs.
    • POST /two-factor/send-email — replays the GET's token, ships an OTP out-of-band that the PUT carries back alongside the same token.
  • Nullable reference types enabled on every new file per ADR 0024 (Details DTOs, new Update response models, new request models).
Behavior changes
  • Dropped the premium gate from GET /two-factor/yubikey and GET /two-factor/duo. The matching PUTs still require premium. Lapsed-premium users can now 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 / chain endpoint, including UV-token validator negative paths (expired, malformed, wrong-user, wrong-provider) and cross-provider replay rejection.
  • The monolithic TwoFactorControllerTests.cs (unit) and TwoFactorControllerTest.cs (integration) files were split into per-provider files under test/Api.Test/Auth/Controllers/TwoFactor/ and test/Api.IntegrationTest/Controllers/TwoFactor/. Each file is 100–300 lines instead of 1600+; shared setup lives in static *TestHelpers classes.
  • Direct unit tests added for the new TwoFactor*Details DTOs (null-handling, masking, key-by-key extraction).
Docs
  • New feature-area README at src/Core/Auth/UserFeatures/TwoFactorAuth/readme.md covering the UV-token flow, validation rules, the chain-endpoint table, and why Authenticator uses a different tokenable shape.
  • XML docs added to IUserService.DisableTwoFactorProviderAsync / IOrganizationService.DisableTwoFactorProviderAsync clarifying that they hard-delete the provider entry (the method names are historical).

📸 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 96.97802% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.56%. Comparing base (2706c74) to head (e9e0fc9).
⚠️ Report is 44 commits behind head on main.

Files with missing lines Patch % Lines
.../Api/Auth/Models/Request/TwoFactorRequestModels.cs 85.18% 3 Missing and 1 partial ⚠️
src/Api/Auth/Controllers/TwoFactorController.cs 96.87% 2 Missing and 1 partial ⚠️
...esponse/TwoFactor/TwoFactorAuthenticatorDetails.cs 94.11% 0 Missing and 1 partial ⚠️
...h/Models/Response/TwoFactor/TwoFactorDuoDetails.cs 97.56% 0 Missing and 1 partial ⚠️
...Models/Response/TwoFactor/TwoFactorEmailDetails.cs 93.33% 0 Missing and 1 partial ⚠️
...els/Response/TwoFactor/TwoFactorWebAuthnDetails.cs 95.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7841      +/-   ##
==========================================
+ Coverage   61.25%   61.56%   +0.31%     
==========================================
  Files        2193     2241      +48     
  Lines       97296    98182     +886     
  Branches     8767     8849      +82     
==========================================
+ Hits        59601    60450     +849     
- Misses      35582    35596      +14     
- Partials     2113     2136      +23     

☔ 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/chain flows onto the TwoFactorUserVerificationTokenable framework. Focused on the new tokenable's binding and expiry semantics, cross-provider/cross-user replay protection, the per-endpoint authorization checks (premium gates and org ManagePolicies/OrganizationAdmin), preserved secret masking in the new Details DTOs, and the removal of the isSettingMFA verification bypass. Token issuance is short-lived (30 min, configurable), bound to (UserId, ProviderType), validated for signature, expiry, and binding on every call, and isolated from the Authenticator tokenable by a distinct data-protector purpose.

Code Review Details

No blocking findings.

Notes considered and cleared during review:

  • Removal of VerifySecretAsync(..., isSettingMFA) eliminates an unconditional verification bypass for passwordless users — a security improvement, with no remaining 3-arg callers.
  • Duo ClientSecret masking and YubiKey/WebAuthn field exposure are unchanged from the prior response models; dropping the premium gate on GET /two-factor/{duo,yubikey} only lets lapsed-premium users read their own already-masked config, and the matching PUTs still enforce premium.
  • Org Duo endpoints re-check ManagePolicies independently of the UV token, so cross-org token replay cannot mutate org state. Cross-provider/cross-user replay is rejected and covered by integration and unit tests.
  • The new tokenable exposes a public initializer for ExpirationDate rather than an internal constructor, but a direct new() yields ExpirationDate == default and fails validation immediately, and this matches the existing OrgUserInviteTokenable pattern.

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.
@JaredSnider-Bitwarden JaredSnider-Bitwarden added the t:breaking-change Change Type - Breaking Change label Jun 23, 2026
@JaredSnider-Bitwarden JaredSnider-Bitwarden removed t:bugfix Change Type - Bugfix t:feature Change Type - Feature Development t:docs Change Type - Documentation labels Jun 24, 2026
… Details

Per ADR 0024, new C# files should opt in to nullable reference types. Drops
the legacy #nullable disable from the new TwoFactor response and Details
files, plus the per-action wrappers that were modified alongside. Properties
that can be null when MetaData lacks the corresponding entry are now declared
string? (Duo Host/ClientId/ClientSecret, Email Email, YubiKey Key1-Key5,
WebAuthn Keys, WebAuthn KeyModel.Name).
…ebauthn

Switches the WebAuthn challenge endpoint from re-collecting the user's secret
to replaying the UV token minted by get-webauthn. The challenge step is now
correctly modeled as a chain step: it validates an existing token, returns
FIDO2 registration options, and leaves the original token valid for the
subsequent PUT. The redundant fresh-mint in the challenge response is
dropped — clients continue to use the original token through the full
enrollment flow. The 2FA feature README's endpoint table grows a Chain
column to surface this interior step (also covers Email's send-email).
…oc summaries

Endpoint URLs and HTTP verbs in C# DTO class summaries rot when routes get
renamed and rarely get updated alongside route changes. The 13 response
models in this folder all repeated the route they're returned from; this
strips those references and describes each model by domain role instead.
@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 t:tech-debt Change Type - Tech debt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant