Skip to content

Auth/PM-41503 and PM-41533 - Registration - Add open org invite flow support - #8159

Open
JaredSnider-Bitwarden wants to merge 9 commits into
mainfrom
auth/pm-41503/registration-start-support-open-org-invites-bypassing-org-domain-block
Open

Auth/PM-41503 and PM-41533 - Registration - Add open org invite flow support #8159
JaredSnider-Bitwarden wants to merge 9 commits into
mainfrom
auth/pm-41503/registration-start-support-open-org-invites-bypassing-org-domain-block

Conversation

@JaredSnider-Bitwarden

@JaredSnider-Bitwarden JaredSnider-Bitwarden commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-41503
https://bitwarden.atlassian.net/browse/PM-41533

📔 Objective

To add proper registration support for registering with a normal email verification token and an open org invite link in client state. This involves not blocking the domain of the link if it is claimed by that org as well as enforcing 2FA required if the org has that enabled.

📸 Screenshots

Integration tests are sufficient.

Narrower AC-owned query that only verifies an open-org-invite link is real (link exists + code matches, org exists + enabled, invite links feature enabled). Sits alongside GetOrganizationInviteLinkStatusQuery, which does additional seat/SSO work meant for display flows. Registration paths only need the validation, so pulling that into its own query avoids the extra seat-count round-trip on every registration attempt that carries an open-org-invite payload.
…e is provided

Register-start and register-finish now accept an OpenOrgInvite payload
({organizationId, code}). When present and validated via the AC-owned
IValidateOrganizationInviteLinkQuery, the invite's organization is
excluded from the BlockClaimedDomain policy check so a user reaching
registration via that org's invite link can proceed with an email whose
domain the org has claimed. Attacker scenarios (mismatched org) remain
blocked because the exclusion is scoped to the specific invite.

Register-finish gains a dedicated
RegisterUserViaEmailVerificationTokenAndOpenOrgInvite method so future
open-invite obligations (2FA policy handling, etc. — PM-41533) can accrue
there without complicating the vanilla EmailVerification path. A DTO
validation rule rejects OpenOrgInvite alongside any non-EmailVerification
token type.
@JaredSnider-Bitwarden JaredSnider-Bitwarden added t:feature Change Type - Feature Development ai-review Request a Claude code review needs-qa labels Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the open-org-invite registration flow across both endpoints: register/send-verification-email and register/finish. The claimed-domain-block exclusion is correctly scoped to the invite's own organization and additionally gated on the link's AllowedDomains, so possession of an {orgId, code} for OrgB cannot unblock a domain claimed by OrgA — the integration tests exercise exactly that attacker path at both endpoints. Email 2FA seeding, the feature-flag gate, and the DTO rule that OpenOrgInvite is only valid alongside an EmailVerificationToken all check out, and all three findings from the prior review pass are resolved at this head.

Code Review Details
  • ♻️ : New IValidateOrganizationInviteLinkQuery is signature-identical to the existing IValidateOrganizationInviteLinkEmailDomainQuery in the same namespace, with inverted success semantics
    • src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs:26

Verified and intentionally not flagged: RegisterUserViaEmailVerificationTokenAndOpenOrgInvite overlapping RegisterUserViaEmailVerificationToken (the interface doc states the divergence is deliberate pending org-membership obligations); the reshaping of SealedOpenOrgInviteData into a nested OpenOrgInvite object (the flat field landed on main within this same cycle and is not yet client-facing); and ValidateOpenRegistrationAllowed() blocking this path on self-hosted instances with registration disabled (consistent with the register-start guard).

… for open-org-invite registration

Extend the open-org-invite registration path so that when the target
organization has "Require 2FA" enabled the user is initialized with
Email 2FA before the User row is persisted, mirroring the direct-invite
path's timing. On success the org is looked up and the org-aware
welcome email variant is dispatched.
Comment thread test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs Dismissed
Comment thread test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs Dismissed
…AllowedDomains

ValidateOrganizationInviteLinkQuery previously proved only that the
link existed, the code matched, and the org was enabled with
UseInviteLinks. That allowed a bearer of the public {orgId, code} to
receive the claimed-domain-block exclusion even when the link's
AllowedDomains would reject the registering email at accept time.

Extend the validator to also require the email's domain to be in the
link's AllowedDomains (using InviteLinkDomainValidator, matching every
other invite-link consumer), returning the canonical EmailDomainNotAllowed
error when it isn't. Both register-start and register-finish now pass
the caller's email through to the validator, so the domain-block
exclusion is only granted when the link would actually admit that email.

Adds AC-owned unit tests for the new check, an integration test at
register-start proving the gap and its closure, and a mirror test at
register-finish so a future single-caller regression is caught.
Comment thread src/Identity/Controllers/AccountsController.cs
…orgId helper

The direct-invite helper's body was byte-for-byte identical to
SetUserEmail2FaIfOrgPolicyEnabledByOrgIdAsync once the org id was in
hand, so any future change to how Email 2FA is seeded would have to be
made twice (or the two registration paths would silently drift). The
outer helper still owns the org-user lookup and the "return the
OrganizationUser to the caller" contract; only the 2FA seeding
delegates.
…k feature flag

Every other invite-link surface (create/get/update/refresh/delete/status/
policies/validate-email-domain, plus a couple of Organization/OrganizationUsers
endpoints) carries [RequireFeature(FeatureFlagKeys.GenerateInviteLink)].
Registration was the only entry point still honoring OpenOrgInvite payloads
regardless of the flag, so flipping the flag off would silence the admin/API
surfaces while registration kept granting the claimed-domain-block exclusion
for links whose management endpoints had already stopped responding.

Guards PostRegisterSendVerificationEmail and PostRegisterFinish with an
inline check that throws FeatureUnavailableException (→ 404) to match how
[RequireFeature] behaves on the sibling surfaces. Every existing integration
test that exercises OpenOrgInvite now declaratively turns the flag on, and
a new register-start test turns it off and asserts 404.
…xture

The new OpenOrgInvite property on RegisterFinishRequestModel was being
auto-populated by AutoFixture in every integration test that seeds a user
via /accounts/register/finish, routing them into the new open-invite
branch and failing token validation with HTTP 400.
@JaredSnider-Bitwarden
JaredSnider-Bitwarden marked this pull request as ready for review August 7, 2026 01:55
@JaredSnider-Bitwarden
JaredSnider-Bitwarden requested review from a team as code owners August 7, 2026 01:55
…test

The new PostRegisterSendEmailVerification_WithOpenOrgInvite_InvalidLink
integration test was using the shared factory, which has the feature
flag off, so the controller's guard returned 404 instead of the 400
the invite-link validator produces. Use a local factory with the flag
enabled, matching the pattern used by the other OpenOrgInvite tests in
this file.
Comment thread test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs Outdated
Both tests assert FeatureUnavailableException, which maps to 404 (not 400).
Renaming to _ThrowsFeatureUnavailable makes the intended status semantics
unambiguous and matches the sibling integration test's naming.
/// feature disabled; <see cref="EmailDomainNotAllowed"/> if the email's domain is not in
/// the link's AllowedDomains.
/// </returns>
Task<CommandResult> ValidateAsync(Guid organizationId, Guid code, string email);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ DEBT: This is signature-identical to the pre-existing IValidateOrganizationInviteLinkEmailDomainQuery in the same namespace, with inverted success semantics.

Details and suggestion

Both live in Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces and expose:

Task<CommandResult>       ValidateAsync(Guid organizationId, Guid code, string email); // new
Task<CommandResult<bool>> ValidateAsync(Guid organizationId, Guid code, string email); // existing

The failure modes read the same at the call site but mean opposite things. For the new query, a disallowed domain is IsError. For the existing one, a disallowed domain is IsSuccess == true with AsSuccess == false. A future caller that injects the existing interface and writes the if (result.IsError) throw; pattern used in RegisterUserCommand and SendVerificationEmailForRegistrationCommand gets a silently-passing domain check on a security boundary.

Separately, the link-lookup + CodeMatches + org enabled/UseInviteLinks sequence is now the third copy (see GetOrganizationInviteLinkStatusQuery and ValidateOrganizationInviteLinkEmailDomainQuery). If invite-link validity ever gains a term (revocation, expiry, seat gating), the registration path is the one most likely to be missed.

Two low-cost options: give this one a name that conveys the fuller scope (e.g. IValidateOrganizationInviteLinkForRegistrationQuery), and/or have it delegate the domain check to IValidateOrganizationInviteLinkEmailDomainQuery so the AllowedDomains rule has a single owner.

The XML doc already contrasts this query with IGetOrganizationInviteLinkStatusQuery; the EmailDomain sibling is the closer overlap and worth calling out there at minimum.

@JaredSnider-Bitwarden JaredSnider-Bitwarden Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eliykat , I could use some feedback here. I think the ValidateOrganizationInviteLinkEmailDomainQuery is not actually sufficient for our needs as it doesn't perform all the checks that we need re ensuring the link is still valid? So... I think this is fine.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't familiar with ValidateOrganizationInviteLinkEmailDomainQuery. It does the same thing as you want here, although I think your implementation is better, because:

  • it checks the organization properties (enabled, useInviteLinks)
  • it uses the CommandResult in a clearer way.

To address this, please:

  • replace the current ValidateOrganizationInviteLinkEmailDomainQuery with yours
  • update the current caller to match the new query interface, but without changing the api contract:
// OrganizationInviteLinksController.ValidateEmailDomain

        var result = await validateOrganizationInviteLinkQuery.ValidateAsync(model.OrganizationId, model.Code, model.Email);

        // Preserve the existing client contract: report the domain check as an IsAllowed boolean
        // rather than surfacing a disallowed domain as an error status.
        if (result is { IsError: true, AsError: EmailDomainNotAllowed })
        {
            return TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(false));
        }

        return Handle(result, _ =>
            TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(true)));

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 63.07%. Comparing base (73ed2f6) to head (b4165a4).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff             @@
##             main    #8159       +/-   ##
===========================================
+ Coverage   15.10%   63.07%   +47.97%     
===========================================
  Files        1417     2318      +901     
  Lines       61422   100594    +39172     
  Branches     4901     9055     +4154     
===========================================
+ Hits         9279    63454    +54175     
+ Misses      51978    34948    -17030     
- Partials      165     2192     +2027     

☔ 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.

@eliykat eliykat left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix, just some adjustments to comments and aligning it with existing code.

return new InviteLinkNotFound();
}

var organization = await organizationRepository.GetByIdAsync(inviteLink.OrganizationId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should use IOrganizationAbilityCacheService.GetOrganizationAbilityAsync instead. (cache vs. db call)

/// feature disabled; <see cref="EmailDomainNotAllowed"/> if the email's domain is not in
/// the link's AllowedDomains.
/// </returns>
Task<CommandResult> ValidateAsync(Guid organizationId, Guid code, string email);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't familiar with ValidateOrganizationInviteLinkEmailDomainQuery. It does the same thing as you want here, although I think your implementation is better, because:

  • it checks the organization properties (enabled, useInviteLinks)
  • it uses the CommandResult in a clearer way.

To address this, please:

  • replace the current ValidateOrganizationInviteLinkEmailDomainQuery with yours
  • update the current caller to match the new query interface, but without changing the api contract:
// OrganizationInviteLinksController.ValidateEmailDomain

        var result = await validateOrganizationInviteLinkQuery.ValidateAsync(model.OrganizationId, model.Code, model.Email);

        // Preserve the existing client contract: report the domain check as an IsAllowed boolean
        // rather than surfacing a disallowed domain as an error status.
        if (result is { IsError: true, AsError: EmailDomainNotAllowed })
        {
            return TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(false));
        }

        return Handle(result, _ =>
            TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(true)));

Comment on lines +7 to +25
/// <summary>
/// Validates that an open organization invite link is usable for the given email — narrower
/// than <see cref="IGetOrganizationInviteLinkStatusQuery"/>, which additionally computes seat
/// availability and SSO status for display to a landing user. This validator exists for flows
/// (e.g., registration) that only need to confirm the link is real, valid, and admits the
/// caller's email — the last check gates the domain-block bypass that the caller applies on
/// success, so possession of the {orgId, code} alone must not be sufficient when the link's
/// AllowedDomains would reject the email at accept time.
/// </summary>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code.</param>
/// <param name="email">The registering user's email; checked against the link's AllowedDomains.</param>
/// <returns>
/// Void success if the link is valid and admits the email; <see cref="InviteLinkNotFound"/>
/// if the link does not exist, the code does not match, or the organization is missing or
/// disabled; <see cref="InviteLinkNotAvailable"/> if the organization has the invite links
/// feature disabled; <see cref="EmailDomainNotAllowed"/> if the email's domain is not in
/// the link's AllowedDomains.
/// </returns>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude xmldoc :((( Way too wordy and too much context associated with this specific PR.

Suggested change
/// <summary>
/// Validates that an open organization invite link is usable for the given email — narrower
/// than <see cref="IGetOrganizationInviteLinkStatusQuery"/>, which additionally computes seat
/// availability and SSO status for display to a landing user. This validator exists for flows
/// (e.g., registration) that only need to confirm the link is real, valid, and admits the
/// caller's email — the last check gates the domain-block bypass that the caller applies on
/// success, so possession of the {orgId, code} alone must not be sufficient when the link's
/// AllowedDomains would reject the email at accept time.
/// </summary>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code.</param>
/// <param name="email">The registering user's email; checked against the link's AllowedDomains.</param>
/// <returns>
/// Void success if the link is valid and admits the email; <see cref="InviteLinkNotFound"/>
/// if the link does not exist, the code does not match, or the organization is missing or
/// disabled; <see cref="InviteLinkNotAvailable"/> if the organization has the invite links
/// feature disabled; <see cref="EmailDomainNotAllowed"/> if the email's domain is not in
/// the link's AllowedDomains.
/// </returns>
/// <summary>
/// Validates that an open organization invite link is valid and that the email
/// matches its allowed domains. It does NOT check that the email has been
/// verified - the caller must check this separately if required.
/// </summary>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code.</param>
/// <param name="email">The registering user's email; checked against the link's AllowedDomains.</param>
/// <returns>
/// A successful CommandResult if the link is valid and matches the email; a failed
/// CommandResult if validation fails or the invite link is otherwise not available.
/// </returns>

Comment on lines +34 to +38
// Gate on the same check every other invite-link consumer uses. Possession of a valid
// {orgId, code} is not sufficient — the link must actually admit this email domain.
// The caller applies a domain-block-policy exclusion on success, so an insufficient
// check here would let a bearer of the code bypass a claimed-domain block by
// registering an email the link would reject at accept time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary comment - it shouldn't document its caller. Please delete.

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 needs-qa t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants