From 612c7027bcf1b8ef011405e306d19f37255f6674 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 18:58:13 -0400 Subject: [PATCH 01/13] PM-41503 - Add ValidateOrganizationInviteLinkQuery 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. --- .../IValidateOrganizationInviteLinkQuery.cs | 22 +++ .../ValidateOrganizationInviteLinkQuery.cs | 35 +++++ ...OrganizationServiceCollectionExtensions.cs | 1 + ...alidateOrganizationInviteLinkQueryTests.cs | 143 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs new file mode 100644 index 000000000000..64a5e0322869 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs @@ -0,0 +1,22 @@ +using Bit.Core.AdminConsole.Utilities.v2.Results; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; + +public interface IValidateOrganizationInviteLinkQuery +{ + /// + /// Validates that an open organization invite link is usable — narrower than + /// , 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 and valid. + /// + /// The organization's ID (from the URL path). + /// The public invite link code. + /// + /// Void success if the link is valid; if the link + /// does not exist, the code does not match, or the organization is missing or disabled; + /// if the organization has the invite links feature + /// disabled. + /// + Task ValidateAsync(Guid organizationId, Guid code); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs new file mode 100644 index 000000000000..dedf7353db3b --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs @@ -0,0 +1,35 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Repositories; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public class ValidateOrganizationInviteLinkQuery( + IOrganizationInviteLinkRepository organizationInviteLinkRepository, + IOrganizationRepository organizationRepository) + : IValidateOrganizationInviteLinkQuery +{ + public async Task ValidateAsync(Guid organizationId, Guid code) + { + var inviteLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId); + if (inviteLink is null || !inviteLink.CodeMatches(code.ToString())) + { + return new InviteLinkNotFound(); + } + + var organization = await organizationRepository.GetByIdAsync(inviteLink.OrganizationId); + if (organization is null or { Enabled: false }) + { + return new InviteLinkNotFound(); + } + + if (!organization.UseInviteLinks) + { + return new InviteLinkNotAvailable(); + } + + return new None(); + } +} diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index fa0b4651acce..7743bd7e99e3 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -210,6 +210,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs new file mode 100644 index 000000000000..a18777ed925d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs @@ -0,0 +1,143 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; + +[SutProviderCustomize] +public class ValidateOrganizationInviteLinkQueryTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_WithValidLink_Success( + OrganizationInviteLink inviteLink, + Organization organization, + SutProvider sutProvider) + { + var code = Guid.NewGuid(); + organization.Id = inviteLink.OrganizationId; + organization.Enabled = true; + organization.UseInviteLinks = true; + inviteLink.Code = code.ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + + Assert.True(result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1).GetByOrganizationIdAsync(inviteLink.OrganizationId); + await sutProvider.GetDependency() + .Received(1).GetByIdAsync(organization.Id); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_InviteLinkNotFound_ReturnsInviteLinkNotFound( + Guid organizationId, + Guid code, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByOrganizationIdAsync(organizationId).ReturnsNull(); + + var result = await sutProvider.Sut.ValidateAsync(organizationId, code); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + // Short-circuit: org repo must not be consulted when the link doesn't exist. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().GetByIdAsync(default); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_CodeMismatch_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, Guid.NewGuid()); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + + // Short-circuit: org repo must not be consulted when the code doesn't match. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs().GetByIdAsync(default); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_OrganizationNotFound_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + SutProvider sutProvider) + { + var code = Guid.NewGuid(); + inviteLink.Code = code.ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(inviteLink.OrganizationId).ReturnsNull(); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + Organization organization, + SutProvider sutProvider) + { + var code = Guid.NewGuid(); + organization.Id = inviteLink.OrganizationId; + organization.Enabled = false; + inviteLink.Code = code.ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_UseInviteLinksFalse_ReturnsInviteLinkNotAvailable( + OrganizationInviteLink inviteLink, + Organization organization, + SutProvider sutProvider) + { + var code = Guid.NewGuid(); + organization.Id = inviteLink.OrganizationId; + organization.Enabled = true; + organization.UseInviteLinks = false; + inviteLink.Code = code.ToString(); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } +} From 9273567fecbc54e458f24e42977f062456ac3bee Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 19:19:35 -0400 Subject: [PATCH 02/13] PM-41503 - Bypass claimed-domain block when a matching open-org-invite is provided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Accounts/OpenOrgInviteRequestModel.cs | 18 + .../Accounts/RegisterFinishRequestModel.cs | 14 +- ...gisterSendVerificationEmailRequestModel.cs | 10 +- .../RegisterStartOpenOrgInviteRequestModel.cs | 19 + .../Registration/IRegisterUserCommand.cs | 21 +- ...VerificationEmailForRegistrationCommand.cs | 13 +- .../Implementations/RegisterUserCommand.cs | 35 ++ ...VerificationEmailForRegistrationCommand.cs | 37 +- .../Controllers/AccountsController.cs | 16 +- .../RegisterFinishRequestModelTests.cs | 98 +++++ .../Registration/RegisterUserCommandTests.cs | 191 ++++++++++ ...icationEmailForRegistrationCommandTests.cs | 212 ++++++++++- .../Controllers/AccountsControllerTests.cs | 360 ++++++++++++++++-- .../Controllers/AccountsControllerTests.cs | 67 +++- 14 files changed, 1033 insertions(+), 78 deletions(-) create mode 100644 src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs create mode 100644 src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs diff --git a/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs new file mode 100644 index 000000000000..9c9a1a2a78b0 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +/// +/// Identifying key for an open organization invite link: the target organization and the +/// link's bearer code. Sent as the register-finish payload directly, and extended by +/// with a sealed data blob at register-start. +/// +public class OpenOrgInviteRequestModel +{ + [Required] + public required Guid OrganizationId { get; set; } + + [Required] + public required Guid Code { get; set; } +} diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs index 56ac6a19868c..20c69ee20d3e 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModel.cs @@ -72,6 +72,8 @@ public class RegisterFinishRequestModel : IValidatableObject public string? SalesAssistedToken { get; set; } + public OpenOrgInviteRequestModel? OpenOrgInvite { get; set; } + public User ToUser(bool isV2Encryption) { // TODO remove IsV2Encryption bool and simplify logic below after a compatibility period - once V2 accounts are supported @@ -245,7 +247,8 @@ public IEnumerable Validate(ValidationContext validationContex [nameof(AccountKeys.PublicKeyEncryptionKeyPair.PublicKey), nameof(AccountKeys.PublicKeyEncryptionKeyPair.WrappedPrivateKey)]); } - // 4. Lastly, validate access token type and presence. Must be done last because of yield break. + // 4. Resolve access token type; short-circuit if no valid token was provided. + // Must be done after all other checks above because of the yield break below. RegisterFinishTokenType tokenType; var tokenTypeResolved = true; try @@ -264,6 +267,15 @@ public IEnumerable Validate(ValidationContext validationContex yield break; } + // 5. OpenOrgInvite is only compatible with the EmailVerification token type. + if (OpenOrgInvite is not null && tokenType != RegisterFinishTokenType.EmailVerification) + { + yield return new ValidationResult( + $"{nameof(OpenOrgInvite)} is only valid with an {nameof(EmailVerificationToken)}.", + [nameof(OpenOrgInvite)]); + } + + // 6. Validate token presence per resolved type. switch (tokenType) { case RegisterFinishTokenType.EmailVerification: diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs index a0265b5b113d..952bd53869e2 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterSendVerificationEmailRequestModel.cs @@ -7,9 +7,6 @@ namespace Bit.Core.Auth.Models.Api.Request.Accounts; public class RegisterSendVerificationEmailRequestModel { - // Bounds the anonymous request body; also caps the derived TrialSendVerificationEmailRequestModel. - private const int SealedOpenOrgInviteDataMaxLength = 4096; - [StringLength(50)] public string? Name { get; set; } [StrictEmailAddress] [StringLength(256)] @@ -18,10 +15,5 @@ public class RegisterSendVerificationEmailRequestModel [MarketingInitiativeValidation] public string? FromMarketing { get; set; } - /// - /// Opaque SDK-produced blob for open-org-invite registrations. Echoed to the verification - /// email URL; never parsed server-side. - /// - [MaxLength(SealedOpenOrgInviteDataMaxLength)] - public string? SealedOpenOrgInviteData { get; set; } + public RegisterStartOpenOrgInviteRequestModel? OpenOrgInvite { get; set; } } diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs new file mode 100644 index 000000000000..314c8eb03f9f --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs @@ -0,0 +1,19 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Core.Auth.Models.Api.Request.Accounts; + +/// +/// Register-start payload for an open organization invite link: the {organizationId, code} +/// reference (inherited from ) plus the opaque SDK-produced +/// sealed blob that is echoed to the verification email URL to enable the registration finish tab +/// to securely reconstitute the open organization invite data. +/// +public class RegisterStartOpenOrgInviteRequestModel : OpenOrgInviteRequestModel +{ + private const int SealedOpenOrgInviteDataMaxLength = 4096; + + [Required] + [MaxLength(SealedOpenOrgInviteDataMaxLength)] + public required string SealedOpenOrgInviteData { get; set; } +} diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index 7df43fb8a368..e0abc38dbb64 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.KeyManagement.Models.Data; using Microsoft.AspNetCore.Identity; @@ -47,7 +48,25 @@ public interface IRegisterUserCommand /// Cryptographic data for finishing user registration /// The email verification token sent to the user via email /// - public Task RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData, string emailVerificationToken); + public Task RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData, + string emailVerificationToken); + + /// + /// Creates a new user via an email-verification token in the presence of a validated + /// . The open org invite's organization is excluded from the claimed-domain + /// block check so a user reaching registration via that org's link can finalize registration + /// with a domain claimed by that org. This path is separate from + /// because the open-org-invite flow will + /// enforce additional org-membership-related obligations that don't apply to vanilla + /// email-verification registration. + /// + /// The to create + /// Cryptographic data for finishing user registration + /// The email verification token sent to the user via email + /// The open-org-invite payload from the client — {orgId, code}. + /// + public Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + User user, RegisterFinishData registerFinishData, string emailVerificationToken, OpenOrgInviteRequestModel openOrgInvite); /// /// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event. diff --git a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs index 789b14773c06..7c714ca0fbc5 100644 --- a/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/ISendVerificationEmailForRegistrationCommand.cs @@ -1,4 +1,6 @@ #nullable enable +using Bit.Core.Auth.Models.Api.Request.Accounts; + namespace Bit.Core.Auth.UserFeatures.Registration; public interface ISendVerificationEmailForRegistrationCommand @@ -7,10 +9,13 @@ public interface ISendVerificationEmailForRegistrationCommand /// Starts the email-verified registration flow; sends a verification email only when the /// email doesn't already belong to an account. /// - /// - /// Optional opaque SDK-produced blob. Echoed to the verification email URL on the new-user - /// branch; dropped on the existing-user branch (anti-enumeration). + /// + /// Optional open-org-invite payload. When present, the sealed blob is echoed to the + /// verification email URL on the new-user branch (dropped on the existing-user branch for + /// anti-enumeration), and the invite's organization is excluded from the claimed-domain + /// block check so a user reaching registration via that org's link can proceed with a + /// domain claimed by that org. /// public Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing, - string? sealedOpenOrgInviteData = null); + RegisterStartOpenOrgInviteRequestModel? openOrgInvite = null); } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 90c9ee6845a3..e75cb5ff22ca 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; @@ -30,6 +32,7 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyQuery _policyQuery; private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IValidateOrganizationInviteLinkQuery _validateOrganizationInviteLinkQuery; private readonly IFeatureService _featureService; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -53,6 +56,7 @@ public RegisterUserCommand( IOrganizationRepository organizationRepository, IPolicyQuery policyQuery, IOrganizationDomainRepository organizationDomainRepository, + IValidateOrganizationInviteLinkQuery validateOrganizationInviteLinkQuery, IFeatureService featureService, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, @@ -69,6 +73,7 @@ public RegisterUserCommand( _organizationRepository = organizationRepository; _policyQuery = policyQuery; _organizationDomainRepository = organizationDomainRepository; + _validateOrganizationInviteLinkQuery = validateOrganizationInviteLinkQuery; _featureService = featureService; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; @@ -284,6 +289,36 @@ public async Task RegisterUserViaEmailVerificationToken(User use return result; } + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + User user, RegisterFinishData registerFinishData, + string emailVerificationToken, OpenOrgInviteRequestModel openOrgInvite) + { + ValidateOpenRegistrationAllowed(); + + var validationResult = await _validateOrganizationInviteLinkQuery.ValidateAsync( + openOrgInvite.OrganizationId, openOrgInvite.Code); + if (validationResult.IsError) + { + throw new BadRequestException("Invalid or expired organization invite link."); + } + + await ValidateEmailDomainNotBlockedAsync(user.Email, openOrgInvite.OrganizationId); + + var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); + + user.EmailVerified = true; + user.Name = tokenable.Name; + user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. + + var result = await _userService.CreateUserAsync(user, registerFinishData); + if (result == IdentityResult.Success) + { + await SendWelcomeEmailAsync(user); + } + + return result; + } + public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 9308d8088be1..0aac90f4e741 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -1,4 +1,6 @@ #nullable enable +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -23,6 +25,7 @@ public class SendVerificationEmailForRegistrationCommand : ISendVerificationEmai private readonly IMailService _mailService; private readonly IDataProtectorTokenFactory _tokenDataFactory; private readonly IOrganizationDomainRepository _organizationDomainRepository; + private readonly IValidateOrganizationInviteLinkQuery _validateOrganizationInviteLinkQuery; public SendVerificationEmailForRegistrationCommand( ILogger logger, @@ -30,7 +33,8 @@ public SendVerificationEmailForRegistrationCommand( GlobalSettings globalSettings, IMailService mailService, IDataProtectorTokenFactory tokenDataFactory, - IOrganizationDomainRepository organizationDomainRepository) + IOrganizationDomainRepository organizationDomainRepository, + IValidateOrganizationInviteLinkQuery validateOrganizationInviteLinkQuery) { _logger = logger; _userRepository = userRepository; @@ -38,11 +42,11 @@ public SendVerificationEmailForRegistrationCommand( _mailService = mailService; _tokenDataFactory = tokenDataFactory; _organizationDomainRepository = organizationDomainRepository; - + _validateOrganizationInviteLinkQuery = validateOrganizationInviteLinkQuery; } public async Task Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing, - string? sealedOpenOrgInviteData = null) + RegisterStartOpenOrgInviteRequestModel? openOrgInvite = null) { if (_globalSettings.DisableUserRegistration) { @@ -54,14 +58,29 @@ public SendVerificationEmailForRegistrationCommand( throw new ArgumentNullException(nameof(email)); } - // Check if the email domain is blocked by an organization policy var emailDomain = EmailValidation.GetDomain(email); - if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(emailDomain)) + // When an open-org-invite payload is present, validate it and use its org as the + // exclusion target for the claimed-domain block check so a user reaching registration + // via that org's link can proceed with a domain the org has claimed. + Guid? excludeOrganizationId = null; + if (openOrgInvite is not null) + { + var validationResult = await _validateOrganizationInviteLinkQuery.ValidateAsync( + openOrgInvite.OrganizationId, openOrgInvite.Code); + if (validationResult.IsError) + { + throw new BadRequestException("Invalid or expired organization invite link."); + } + excludeOrganizationId = openOrgInvite.OrganizationId; + } + + if (await _organizationDomainRepository.HasVerifiedDomainWithBlockClaimedDomainPolicyAsync( + emailDomain, excludeOrganizationId)) { _logger.LogInformation( - "User registration email verification blocked by domain claim policy. Domain: {Domain}", - emailDomain); + "User registration email verification blocked by domain claim policy. Domain: {Domain}, ExcludedOrgId: {ExcludedOrgId}", + emailDomain, excludeOrganizationId); throw new BadRequestException("This email address is claimed by an organization using Bitwarden."); } @@ -87,7 +106,8 @@ public SendVerificationEmailForRegistrationCommand( // If the user doesn't exist, create a new EmailVerificationTokenable and send the user // an email with a link to verify their email address var token = GenerateToken(email, name, receiveMarketingEmails); - await _mailService.SendRegistrationVerificationEmailAsync(email, token, fromMarketing, sealedOpenOrgInviteData); + await _mailService.SendRegistrationVerificationEmailAsync( + email, token, fromMarketing, openOrgInvite?.SealedOpenOrgInviteData); } // User exists but we will return a 200 regardless of whether the email was sent or not; so return null @@ -100,4 +120,3 @@ private string GenerateToken(string email, string? name, bool receiveMarketingEm return _tokenDataFactory.Protect(registrationEmailVerificationTokenable); } } - diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 5075849602bc..20c35385a489 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -103,7 +103,7 @@ GlobalSettings globalSettings public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name, - model.ReceiveMarketingEmails, model.FromMarketing, model.SealedOpenOrgInviteData); + model.ReceiveMarketingEmails, model.FromMarketing, model.OpenOrgInvite); if (token != null) { @@ -143,10 +143,16 @@ public async Task PostRegisterFinish([FromBody] Reg switch (model.GetTokenType()) { case RegisterFinishTokenType.EmailVerification: - identityResult = await _registerUserCommand.RegisterUserViaEmailVerificationToken( - user, - registerFinishData, - model.EmailVerificationToken!); + identityResult = model.OpenOrgInvite is not null + ? await _registerUserCommand.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, + registerFinishData, + model.EmailVerificationToken!, + model.OpenOrgInvite) + : await _registerUserCommand.RegisterUserViaEmailVerificationToken( + user, + registerFinishData, + model.EmailVerificationToken!); return ProcessRegistrationResult(identityResult, user); case RegisterFinishTokenType.OrganizationInvite: diff --git a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs index 96b451fbafe2..c9adf7bbe723 100644 --- a/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs +++ b/test/Core.Test/Auth/Models/Api/Request/Accounts/RegisterFinishRequestModelTests.cs @@ -514,6 +514,104 @@ public void Validate_WhenSaltMismatchBetweenAuthAndUnlock_ReturnsSaltEqualityErr Assert.Contains(results, r => r.ErrorMessage == "Invalid master password salt."); } + private static RegisterFinishRequestModel BuildValidBaseModelWithoutToken() => new() + { + Email = "user@example.com", + UserAsymmetricKeys = new KeysRequestModel { PublicKey = "pk", EncryptedPrivateKey = "sk" }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = KdfConstants.PBKDF2_ITERATIONS.Default }, + MasterKeyWrappedUserKey = "wrapped", + Salt = "salt" + }, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + Kdf = new KdfRequestModel { KdfType = KdfType.PBKDF2_SHA256, Iterations = KdfConstants.PBKDF2_ITERATIONS.Default }, + MasterPasswordAuthenticationHash = "auth-hash", + Salt = "salt" + } + }; + + [Fact] + public void Validate_OpenOrgInviteWithEmailVerificationToken_IsValid() + { + var model = BuildValidBaseModelWithoutToken(); + model.EmailVerificationToken = "token"; + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Empty(results); + } + + [Fact] + public void Validate_OpenOrgInviteWithOrgInviteToken_ReturnsError() + { + var model = BuildValidBaseModelWithoutToken(); + model.OrgInviteToken = "org-invite-token"; + model.OrganizationUserId = Guid.NewGuid(); + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.OpenOrgInvite)} is only valid with an {nameof(RegisterFinishRequestModel.EmailVerificationToken)}."); + } + + [Fact] + public void Validate_OpenOrgInviteWithOrgSponsoredFreeFamilyPlanToken_ReturnsError() + { + var model = BuildValidBaseModelWithoutToken(); + model.OrgSponsoredFreeFamilyPlanToken = "org-sponsored-token"; + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.OpenOrgInvite)} is only valid with an {nameof(RegisterFinishRequestModel.EmailVerificationToken)}."); + } + + [Fact] + public void Validate_OpenOrgInviteWithAcceptEmergencyAccessInviteToken_ReturnsError() + { + var model = BuildValidBaseModelWithoutToken(); + model.AcceptEmergencyAccessInviteToken = "emergency-access-token"; + model.AcceptEmergencyAccessId = Guid.NewGuid(); + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.OpenOrgInvite)} is only valid with an {nameof(RegisterFinishRequestModel.EmailVerificationToken)}."); + } + + [Fact] + public void Validate_OpenOrgInviteWithProviderInviteToken_ReturnsError() + { + var model = BuildValidBaseModelWithoutToken(); + model.ProviderInviteToken = "provider-invite-token"; + model.ProviderUserId = Guid.NewGuid(); + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.OpenOrgInvite)} is only valid with an {nameof(RegisterFinishRequestModel.EmailVerificationToken)}."); + } + + [Fact] + public void Validate_OpenOrgInviteWithSalesAssistedToken_ReturnsError() + { + var model = BuildValidBaseModelWithoutToken(); + model.SalesAssistedToken = "sales-assisted-token"; + model.OpenOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = Guid.NewGuid(), Code = Guid.NewGuid() }; + + var results = Validate(model); + + Assert.Contains(results, r => + r.ErrorMessage == $"{nameof(RegisterFinishRequestModel.OpenOrgInvite)} is only valid with an {nameof(RegisterFinishRequestModel.EmailVerificationToken)}."); + } + [Fact] public void Validate_WhenNoValidRegistrationTokenProvided_ReturnsTokenErrorOnly() { diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index d746381e6437..1fac7cf6502d 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -2,9 +2,13 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Billing.Enums; @@ -24,6 +28,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.WebUtilities; using NSubstitute; +using OneOf.Types; using Xunit; using EmergencyAccessEntity = Bit.Core.Auth.Entities.EmergencyAccess; @@ -701,6 +706,192 @@ public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration } + // ----------------------------------------------------------------------------------------------- + // RegisterUserViaEmailVerificationTokenAndOpenOrgInvite tests + // ----------------------------------------------------------------------------------------------- + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ValidLink_PassesExcludeOrgId( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, + Guid organizationId, Guid code) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .CreateUserAsync(user, registerFinishData) + .Returns(IdentityResult.Success); + + // Act + await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organizationId); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidCode_ThrowsBadRequest( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, Guid organizationId, Guid code) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new InviteLinkNotFound())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); + Assert.Equal("Invalid or expired organization invite link.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksDisabled_ThrowsBadRequest( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, Guid organizationId, Guid code) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new InviteLinkNotAvailable())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); + Assert.Equal("Invalid or expired organization invite link.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksEnabled_UnblocksClaimedDomain( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, + Guid organizationId, Guid code) + { + // Arrange + user.Email = "user@claimed-domain.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + // Excluded-org path returns false; unfiltered path would return true. + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("claimed-domain.com", (Guid?)null) + .Returns(true); + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("claimed-domain.com", organizationId) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .CreateUserAsync(user, registerFinishData) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_OpenRegistrationDisabled_ThrowsBadRequestException( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, Guid organizationId, Guid code) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .DisableUserRegistration = true; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); + Assert.Equal("Open registration has been disabled by the system administrator.", exception.Message); + + // Short-circuit: registration guard must run before the invite validator and token check. + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ValidateAsync(default, default); + sutProvider.GetDependency>() + .DidNotReceiveWithAnyArgs() + .TryUnprotect(default, out Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidToken_ThrowsBadRequestException( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, Guid organizationId, Guid code) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + // Token unprotect yields a tokenable bound to a different email. + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable("wrongEmail@test.com", user.Name, receiveMarketingMaterials); + return true; + }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); + Assert.Equal("Invalid email verification token.", exception.Message); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken tests // ----------------------------------------------------------------------------------------------- diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index 5230faffb01c..9460c84ee435 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -1,4 +1,7 @@ -using Bit.Core.Auth.Models.Api.Request.Accounts; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; +using Bit.Core.AdminConsole.Utilities.v2.Results; +using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; using Bit.Core.Entities; @@ -10,6 +13,7 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ReturnsExtensions; +using OneOf.Types; using Xunit; using GlobalSettings = Bit.Core.Settings.GlobalSettings; @@ -18,6 +22,16 @@ namespace Bit.Core.Test.Auth.UserFeatures.Registration; [SutProviderCustomize] public class SendVerificationEmailForRegistrationCommandTests { + private static RegisterStartOpenOrgInviteRequestModel BuildOpenOrgInvite( + Guid organizationId, + Guid code, + string sealedOpenOrgInviteData = "opaque-base64url-sealed-data") => + new() + { + OrganizationId = organizationId, + Code = code, + SealedOpenOrgInviteData = sealedOpenOrgInviteData, + }; [Theory] [BitAutoData] @@ -306,13 +320,13 @@ public async Task SendVerificationEmailForRegistrationCommand_InvalidEmailFormat [Theory] [BitAutoData] - public async Task SendVerificationEmailForRegistrationCommand_WhenNewUserAndSealedOpenOrgInviteDataProvided_ForwardsSealedDataToMailService( + public async Task SendVerificationEmailForRegistrationCommand_WhenNewUserAndOpenOrgInviteProvided_ForwardsSealedDataToMailService( SutProvider sutProvider, - string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) { // Arrange var email = $"test+{Guid.NewGuid()}@example.com"; - var sealedOpenOrgInviteData = "opaque-base64url-sealed-data"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); sutProvider.GetDependency() .GetByEmailAsync(email) @@ -325,34 +339,38 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenNewUserAndSeal .DisableUserRegistration = false; sutProvider.GetDependency() - .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) .Returns(false); + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + var mockedToken = "token"; sutProvider.GetDependency>() .Protect(Arg.Any()) .Returns(mockedToken); // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, sealedOpenOrgInviteData); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite); // Assert await sutProvider.GetDependency() .Received(1) - .SendRegistrationVerificationEmailAsync(email, mockedToken, null, sealedOpenOrgInviteData); + .SendRegistrationVerificationEmailAsync(email, mockedToken, null, openOrgInvite.SealedOpenOrgInviteData); Assert.Null(result); } [Theory] [BitAutoData] - public async Task SendVerificationEmailForRegistrationCommand_WhenExistingUserAndSealedOpenOrgInviteDataProvided_SilentlyDiscardsSealedData( + public async Task SendVerificationEmailForRegistrationCommand_WhenExistingUserAndOpenOrgInviteProvided_SilentlyDiscardsSealedData( SutProvider sutProvider, - string name, bool receiveMarketingEmails) + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) { // Existing-user branch: response mirrors the new-user path with the sealed data dropped (anti-enumeration). // Arrange var email = $"test+{Guid.NewGuid()}@example.com"; - var sealedOpenOrgInviteData = "opaque-base64url-sealed-data"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); sutProvider.GetDependency() .GetByEmailAsync(email) @@ -365,11 +383,15 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenExistingUserAn .DisableUserRegistration = false; sutProvider.GetDependency() - .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any()) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) .Returns(false); + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + // Act - var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, sealedOpenOrgInviteData); + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite); // Assert await sutProvider.GetDependency() @@ -377,4 +399,170 @@ await sutProvider.GetDependency() .SendRegistrationVerificationEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); Assert.Null(result); } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Null_UsesUnfilteredDomainBlockCheck( + SutProvider sutProvider, + string name, bool receiveMarketingEmails) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + // Act + await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite: null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), null); + + await sutProvider.GetDependency() + .DidNotReceive() + .ValidateAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Provided_ValidLink_PassesExcludeOrgId( + SutProvider sutProvider, + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); + + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + // Act + await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organizationId); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Provided_InvalidCode_ThrowsBadRequest( + SutProvider sutProvider, + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new InviteLinkNotFound())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite)); + Assert.Equal("Invalid or expired organization invite link.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Provided_LinksDisabled_ThrowsBadRequest( + SutProvider sutProvider, + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new InviteLinkNotAvailable())); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite)); + Assert.Equal("Invalid or expired organization invite link.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Provided_LinksEnabled_UnblocksClaimedDomain( + SutProvider sutProvider, + string name, bool receiveMarketingEmails, Guid organizationId, Guid code) + { + // Arrange + var email = $"test+{Guid.NewGuid()}@claimeddomain.com"; + var openOrgInvite = BuildOpenOrgInvite(organizationId, code); + + sutProvider.GetDependency() + .GetByEmailAsync(email) + .ReturnsNull(); + + sutProvider.GetDependency() + .EnableEmailVerification = true; + + sutProvider.GetDependency() + .DisableUserRegistration = false; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + // Excluded-org path returns false; unfiltered path would return true. Verifies the excludeOrganizationId branch is taken. + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("claimeddomain.com", (Guid?)null) + .Returns(true); + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("claimeddomain.com", organizationId) + .Returns(false); + + var mockedToken = "token"; + sutProvider.GetDependency>() + .Protect(Arg.Any()) + .Returns(mockedToken); + + // Act + var result = await sutProvider.Sut.Run(email, name, receiveMarketingEmails, null, openOrgInvite); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendRegistrationVerificationEmailAsync(email, mockedToken, null, openOrgInvite.SealedOpenOrgInviteData); + Assert.Null(result); + } } diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 9aec0f2b8a0f..06fc1e914dba 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,6 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; @@ -96,38 +99,31 @@ public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser__ } [Theory, BitAutoData] - public async Task PostRegisterSendEmailVerification_WithSealedOpenOrgInviteData_ReturnsNoContent(string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_InvalidLink_ReturnsBadRequest(string name, bool receiveMarketingEmails) { - // Localized factory so we can inspect the mail service in isolation. - var localFactory = new IdentityApplicationFactory(); - - var email = $"test+register+sealed+{name}@email.com"; - var sealedOpenOrgInviteData = "opaque-base64url-blob-representing-a-realistic-sdk-output"; + // OpenOrgInvite payload without a matching invite link on the org is rejected. + var email = $"test+register+badlink+{name}@email.com"; var model = new RegisterSendVerificationEmailRequestModel { Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails, - SealedOpenOrgInviteData = sealedOpenOrgInviteData, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = Guid.NewGuid(), + Code = Guid.NewGuid(), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, }; - var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); - - Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + var context = await _factory.PostRegisterSendEmailVerificationAsync(model); - // The passthrough must reach the mail service unchanged — the server never parses it. - await localFactory.GetService() - .Received(1) - .SendRegistrationVerificationEmailAsync( - Arg.Is(e => e == email), - Arg.Any(), - Arg.Any(), - Arg.Is(s => s == sealedOpenOrgInviteData)); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } [Theory, BitAutoData] - public async Task PostRegisterSendEmailVerification_WithOversizedSealedOpenOrgInviteData_ReturnsBadRequest(string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_OversizedSealedData_ReturnsBadRequest(string name, bool receiveMarketingEmails) { var email = $"test+register+oversize+{name}@email.com"; // Length cap in the request model is 4096; 4097+ must be rejected by model validation. @@ -138,7 +134,12 @@ public async Task PostRegisterSendEmailVerification_WithOversizedSealedOpenOrgIn Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails, - SealedOpenOrgInviteData = oversized, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = Guid.NewGuid(), + Code = Guid.NewGuid(), + SealedOpenOrgInviteData = oversized, + }, }; var context = await _factory.PostRegisterSendEmailVerificationAsync(model); @@ -147,33 +148,144 @@ public async Task PostRegisterSendEmailVerification_WithOversizedSealedOpenOrgIn } [Theory, BitAutoData] - public async Task PostRegisterSendEmailVerification_WithSealedOpenOrgInviteData_ForExistingUser_SilentlyDiscardsSealedData(string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_WithMatchingOrgInvite_BypassesClaimedDomainBlock(string name, bool receiveMarketingEmails) { - // Existing user + sealed data → 204 with no mail sent (anti-enumeration). + // Isolated factory to keep the seeded org/policy/domain out of the shared fixture. var localFactory = new IdentityApplicationFactory(); - var email = $"test+register+existing+{name}@email.com"; - await CreateUserAsync(email, name, localFactory); + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+claimed+{name}@{claimedDomain}"; + var (_, inviteLink) = await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); var model = new RegisterSendVerificationEmailRequestModel { Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails, - SealedOpenOrgInviteData = "opaque-base64url-blob", + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, }; var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + [Theory, BitAutoData] + public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_StillBlocksClaimedDomain(string name, bool receiveMarketingEmails) + { + // Attacker scenario: sender's invite belongs to OrgB, but the email's domain is claimed by OrgA. + // OrgA's block policy must still fire because the exclusion is scoped to OrgB, not OrgA. + var localFactory = new IdentityApplicationFactory(); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+attacker+{name}@{claimedDomain}"; + await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); + var (_, attackerInviteLink) = await SeedOrgWithInviteLinkAsync(localFactory); + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = attackerInviteLink.OrganizationId, + Code = Guid.Parse(attackerInviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + + var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); - await localFactory.GetService() - .DidNotReceive() - .SendRegistrationVerificationEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } + + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithClaimedDomainAndInviteLinkAsync( + IdentityApplicationFactory factory, string claimedDomain) + { + var organizationRepository = factory.Services.GetRequiredService(); + var organizationDomainRepository = factory.Services.GetRequiredService(); + var policyRepository = factory.Services.GetRequiredService(); + var organizationInviteLinkRepository = factory.Services.GetRequiredService(); + + var organization = new Organization + { + Name = $"ClaimedDomainOrg-{Guid.NewGuid():N}", + BillingEmail = $"billing+{Guid.NewGuid():N}@example.com", + Plan = "Enterprise", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true, + UseInviteLinks = true, + }; + organization = await organizationRepository.CreateAsync(organization); + + var domain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = claimedDomain, + Txt = "bw-test", + }; + domain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(domain); + + var policy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true, + }; + await policyRepository.CreateAsync(policy); + + var inviteLink = new OrganizationInviteLink + { + OrganizationId = organization.Id, + Invite = "opaque-invite-blob", + SupportsConfirmation = false, + }; + inviteLink.SetAllowedDomains(new[] { claimedDomain }); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return (organization, inviteLink); + } + + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAsync( + IdentityApplicationFactory factory) + { + var organizationRepository = factory.Services.GetRequiredService(); + var organizationInviteLinkRepository = factory.Services.GetRequiredService(); + + var organization = new Organization + { + Name = $"OtherOrg-{Guid.NewGuid():N}", + BillingEmail = $"billing+{Guid.NewGuid():N}@example.com", + Plan = "Enterprise", + Enabled = true, + UsePolicies = true, + UseInviteLinks = true, + }; + organization = await organizationRepository.CreateAsync(organization); + + var inviteLink = new OrganizationInviteLink + { + OrganizationId = organization.Id, + Invite = "opaque-invite-blob", + SupportsConfirmation = false, + AllowedDomains = "[]", + }; + inviteLink.SetAllowedDomains(Array.Empty()); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return (organization, inviteLink); } @@ -314,6 +426,190 @@ public async Task RegistrationWithEmailVerification_OpenRegistrationDisabled_Thr Assert.Equal(StatusCodes.Status400BadRequest, postRegisterFinishHttpContext.Response.StatusCode); } + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithMatchingOpenOrgInvite_Succeeds([Required] string name, bool receiveMarketingEmails, + [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, [Required] string userSymmetricKey, + [Required] KeysRequestModel userAsymmetricKeys, int kdfMemory, int kdfParallelism) + { + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+claimedfinish+{name}@{claimedDomain}"; + var (_, inviteLink) = await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); + + // Register-start with the matching invite — bypasses the claimed-domain block, yields a token. + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + Assert.NotNull(localFactory.RegistrationTokens[email]); + + // Register-finish with the same invite — the domain-block check must exclude this org. + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status200OK, finishCtx.Response.StatusCode); + + var database = localFactory.GetDatabaseContext(); + var user = await database.Users.SingleAsync(u => u.Email == email); + Assert.NotNull(user); + Assert.Equal(email, user.Email); + Assert.Equal(name, user.Name); + } + + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithInvalidOpenOrgInvite_ReturnsBadRequest([Required] string name, + [StringLength(1000), Required] string masterPasswordHash, [Required] string userSymmetricKey, + [Required] KeysRequestModel userAsymmetricKeys) + { + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + + var email = $"test+register+badfinishlink+{name}@email.com"; + + // Register-start with no invite to obtain a plain email verification token. + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + + // Register-finish carrying a bogus OpenOrgInvite — validator query returns InviteLinkNotFound → 400. + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = Guid.NewGuid(), + Code = Guid.NewGuid(), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status400BadRequest, finishCtx.Response.StatusCode); + } + + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithDifferentOrgOpenOrgInvite_StillBlocksClaimedDomain([Required] string name, + [StringLength(1000), Required] string masterPasswordHash, [Required] string userSymmetricKey, + [Required] KeysRequestModel userAsymmetricKeys) + { + // Attacker at register-finish: the token was obtained via OrgA's invite (the legitimate claimant), + // but the caller now swaps OrgB's invite into the register-finish body hoping to reach past OrgA's + // block policy. The domain-block check must still exclude only OrgB, so OrgA's policy fires → 400. + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+attackerfinish+{name}@{claimedDomain}"; + var (_, orgAInvite) = await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); + var (_, orgBInvite) = await SeedOrgWithInviteLinkAsync(localFactory); + + // Register-start with OrgA's invite so the claimed-domain block is bypassed and we receive a token. + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = orgAInvite.OrganizationId, + Code = Guid.Parse(orgAInvite.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + Assert.NotNull(localFactory.RegistrationTokens[email]); + + // Register-finish swaps to OrgB's invite. Exclusion is scoped to OrgB, OrgA's policy still fires. + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = orgBInvite.OrganizationId, + Code = Guid.Parse(orgBInvite.Code), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status400BadRequest, finishCtx.Response.StatusCode); + } + + [Theory, BitAutoData] + public async Task RegisterFinish_WithOpenOrgInviteAndOrgInviteToken_ReturnsBadRequest([Required] string name, + [StringLength(1000), Required] string masterPasswordHash, [Required] string userSymmetricKey, + [Required] KeysRequestModel userAsymmetricKeys, string orgInviteToken) + { + // DTO validation: OpenOrgInvite is only compatible with the EmailVerification token type. + // Sending it alongside an OrgInviteToken must be rejected at the model-validation layer → 400. + userAsymmetricKeys.AccountKeys = null; + var email = $"test+register+dtoreject+{name}@email.com"; + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + OrgInviteToken = orgInviteToken, + OrganizationUserId = Guid.NewGuid(), + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = Guid.NewGuid(), + Code = Guid.NewGuid(), + }, + }; + + var finishCtx = await _factory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status400BadRequest, finishCtx.Response.StatusCode); + } + [Theory, BitAutoData] public async Task RegistrationWithEmailVerification_WithOrgInviteToken_Succeeds( [StringLength(1000)] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, string userSymmetricKey, diff --git a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs index 35effefeb8ed..4baab40be2ee 100644 --- a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Exceptions; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Models.Api.Request; +using Bit.Core.KeyManagement.Models.Data; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; @@ -323,17 +324,22 @@ await _sendVerificationEmailForRegistrationCommand.Received(1) [Theory] [BitAutoData] - public async Task PostRegisterSendEmailVerification_PassesSealedOpenOrgInviteDataToCommandAsync( - string email, string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_ForwardsOpenOrgInvite( + string email, string name, bool receiveMarketingEmails, Guid organizationId, Guid code) { // Arrange - var sealedOpenOrgInviteData = "opaque-base64url-blob"; + var openOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = organizationId, + Code = code, + SealedOpenOrgInviteData = "opaque-base64url-blob", + }; var model = new RegisterSendVerificationEmailRequestModel { Email = email, Name = name, ReceiveMarketingEmails = receiveMarketingEmails, - SealedOpenOrgInviteData = sealedOpenOrgInviteData, + OpenOrgInvite = openOrgInvite, }; // Act @@ -341,7 +347,7 @@ public async Task PostRegisterSendEmailVerification_PassesSealedOpenOrgInviteDat // Assert await _sendVerificationEmailForRegistrationCommand.Received(1) - .Run(email, name, receiveMarketingEmails, null, sealedOpenOrgInviteData); + .Run(email, name, receiveMarketingEmails, null, openOrgInvite); } [Theory, BitAutoData, SignatureKeyPairRequestModelCustomizeAttribute] @@ -603,6 +609,57 @@ await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationToken(Arg ), newData, emailVerificationToken); } + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] + public async Task PostRegisterFinish_EmailVerification_ForwardsOpenOrgInvite( + string email, string emailVerificationToken, string userSymmetricKey, string masterPasswordHash, + AccountKeysRequestModel accountKeys, Guid organizationId, Guid code) + { + // Arrange + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = KdfConstants.ARGON2_ITERATIONS.Default, + Memory = KdfConstants.ARGON2_MEMORY.Default, + Parallelism = KdfConstants.ARGON2_PARALLELISM.Default, + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + OpenOrgInvite = openOrgInvite, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys, + }; + + _registerUserCommand.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + Arg.Any(), Arg.Any(), emailVerificationToken, openOrgInvite) + .Returns(Task.FromResult(IdentityResult.Success)); + + // Act + var result = await _sut.PostRegisterFinish(model); + + // Assert + Assert.NotNull(result); + await _registerUserCommand.Received(1).RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + Arg.Any(), Arg.Any(), emailVerificationToken, openOrgInvite); + await _registerUserCommand.DidNotReceive().RegisterUserViaEmailVerificationToken( + Arg.Any(), Arg.Any(), Arg.Any()); + } + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUser_ThrowsBadRequestException( string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, From d186b509d5747d8b6cf0f3a7b6e4bbb520bc94b8 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 19:50:06 -0400 Subject: [PATCH 03/13] PM-41533 - Auto-enable Email 2FA and dispatch org-aware welcome email 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. --- .../Implementations/RegisterUserCommand.cs | 29 +- .../Registration/RegisterUserCommandTests.cs | 379 +++++++++++++++++- .../Controllers/AccountsControllerTests.cs | 106 +++++ 3 files changed, 511 insertions(+), 3 deletions(-) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index e75cb5ff22ca..c5978c4c9a01 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -306,6 +306,8 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOr var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); + await SetUserEmail2FaIfOrgPolicyEnabledByOrgIdAsync(openOrgInvite.OrganizationId, user); + user.EmailVerified = true; user.Name = tokenable.Name; user.ApiKey = CoreHelpers.SecureRandomString(30); // API key can't be null. @@ -313,12 +315,37 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOr var result = await _userService.CreateUserAsync(user, registerFinishData); if (result == IdentityResult.Success) { - await SendWelcomeEmailAsync(user); + var organization = await _organizationRepository.GetByIdAsync(openOrgInvite.OrganizationId); + await SendWelcomeEmailAsync(user, organization); } return result; } + /// + /// Parallel of for callers that already know + /// the target organization id and have no OrganizationUser row to look up (e.g. open-org-invite, + /// where the invite has not yet been accepted). + /// + private async Task SetUserEmail2FaIfOrgPolicyEnabledByOrgIdAsync(Guid organizationId, User user) + { + var twoFactorPolicy = await _policyQuery.RunAsync(organizationId, PolicyType.TwoFactorAuthentication); + if (!twoFactorPolicy.Enabled) + { + return; + } + + user.SetTwoFactorProviders(new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + Enabled = true + } + }); + _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); + } + public async Task RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken(User user, RegisterFinishData registerFinishData, string orgSponsoredFreeFamilyPlanInviteToken) { diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 1fac7cf6502d..3f33303967c3 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -715,7 +715,8 @@ public async Task RegisterUserViaEmailVerificationToken_DisabledOpenRegistration public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ValidLink_PassesExcludeOrgId( SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials, - Guid organizationId, Guid code) + Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = $"test+{Guid.NewGuid()}@example.com"; @@ -737,6 +738,10 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ValidLin return true; }); + sutProvider.GetDependency() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + sutProvider.GetDependency() .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); @@ -768,6 +773,10 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidC var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); Assert.Equal("Invalid or expired organization invite link.", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); } [Theory] @@ -788,6 +797,10 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksDis var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); Assert.Equal("Invalid or expired organization invite link.", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); } [Theory] @@ -795,7 +808,8 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksDis public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksEnabled_UnblocksClaimedDomain( SutProvider sutProvider, User user, RegisterFinishData registerFinishData, string emailVerificationToken, bool receiveMarketingMaterials, - Guid organizationId, Guid code) + Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) { // Arrange user.Email = "user@claimed-domain.com"; @@ -821,6 +835,10 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksEna return true; }); + sutProvider.GetDependency() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + sutProvider.GetDependency() .CreateUserAsync(user, registerFinishData) .Returns(IdentityResult.Success); @@ -857,6 +875,9 @@ await sutProvider.GetDependency() sutProvider.GetDependency>() .DidNotReceiveWithAnyArgs() .TryUnprotect(default, out Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); } [Theory] @@ -890,6 +911,360 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidT var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); Assert.Equal("Invalid email verification token.", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_TwoFactorPolicyEnabled_SeedsEmail2FaBeforeCreate( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + user.TwoFactorProviders = null; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + // Capture the User state at CreateUserAsync time so we can assert 2FA was seeded BEFORE create. + string? twoFactorProvidersAtCreate = null; + sutProvider.GetDependency() + .CreateUserAsync(Arg.Do(u => twoFactorProvidersAtCreate = u.TwoFactorProviders), registerFinishData) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication); + + sutProvider.GetDependency() + .Received(1) + .SetTwoFactorProvider(user, TwoFactorProviderType.Email); + + var expectedTwoFactorProviders = new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + Enabled = true + } + }; + var expectedSerialized = JsonHelpers.LegacySerialize(expectedTwoFactorProviders, JsonHelpers.LegacyEnumKeyResolver); + Assert.Equal(expectedSerialized, twoFactorProvidersAtCreate); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_TwoFactorPolicyDisabled_DoesNotTouch2FA( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + user.TwoFactorProviders = null; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + string? twoFactorProvidersAtCreate = "sentinel"; + sutProvider.GetDependency() + .CreateUserAsync(Arg.Do(u => twoFactorProvidersAtCreate = u.TwoFactorProviders), registerFinishData) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication); + + sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SetTwoFactorProvider(default, default); + + Assert.Null(twoFactorProvidersAtCreate); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_Succeeds_SendsOrgAwareWelcomeEmail( + PlanType planType, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + Organization organization, string emailVerificationToken, bool receiveMarketingMaterials, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + organization.PlanType = planType; + organization.Name = "Open Invite Org"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organization.Id, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + sutProvider.GetDependency() + .CreateUserAsync(user, registerFinishData) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.Free)] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_Succeeds_FreeOrFamiliesOrg_SendsFamiliesWelcomeEmail( + PlanType planType, + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + Organization organization, string emailVerificationToken, bool receiveMarketingMaterials, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + organization.PlanType = planType; + organization.Name = "Families Org"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organization.Id, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + sutProvider.GetDependency() + .CreateUserAsync(user, registerFinishData) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_DomainBlockFires_ShortCircuitsBeforePolicyCheck( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, Guid organizationId, Guid code) + { + // Arrange + user.Email = "user@blocked-domain.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code) + .Returns(new CommandResult(new None())); + + // The excluded-org filter still returns true — some OTHER org has claimed the domain. + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync("blocked-domain.com", organizationId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(user, registerFinishData, emailVerificationToken, openOrgInvite)); + Assert.Equal("This email address is claimed by an organization using Bitwarden.", exception.Message); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ComplexHappyPath_TwoFactorPolicyAndOrgAwareWelcomeEmail_Succeeds( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + Organization organization, string emailVerificationToken, bool receiveMarketingMaterials, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + user.TwoFactorProviders = null; + organization.PlanType = PlanType.EnterpriseAnnually; + organization.Name = "Enterprise Open Invite Org"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organization.Id, code) + .Returns(new CommandResult(new None())); + + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Any()) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organization.Id, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + string? twoFactorProvidersAtCreate = null; + sutProvider.GetDependency() + .CreateUserAsync(Arg.Do(u => twoFactorProvidersAtCreate = u.TwoFactorProviders), registerFinishData) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + // 2FA seeded before create, in the expected shape. + sutProvider.GetDependency() + .Received(1) + .SetTwoFactorProvider(user, TwoFactorProviderType.Email); + var expectedTwoFactorProviders = new Dictionary + { + [TwoFactorProviderType.Email] = new TwoFactorProvider + { + MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + Enabled = true + } + }; + var expectedSerialized = JsonHelpers.LegacySerialize(expectedTwoFactorProviders, JsonHelpers.LegacyEnumKeyResolver); + Assert.Equal(expectedSerialized, twoFactorProvidersAtCreate); + + // Org-aware welcome email dispatched on success. + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organization.Id); + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); } // ----------------------------------------------------------------------------------------------- diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 06fc1e914dba..46faff763742 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Entities; @@ -256,6 +257,46 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still return (organization, inviteLink); } + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAndTwoFactorPolicyAsync( + IdentityApplicationFactory factory) + { + var organizationRepository = factory.Services.GetRequiredService(); + var policyRepository = factory.Services.GetRequiredService(); + var organizationInviteLinkRepository = factory.Services.GetRequiredService(); + + var organization = new Organization + { + Name = $"TwoFactorOrg-{Guid.NewGuid():N}", + BillingEmail = $"billing+{Guid.NewGuid():N}@example.com", + Plan = "Enterprise", + Enabled = true, + UsePolicies = true, + UseInviteLinks = true, + }; + organization = await organizationRepository.CreateAsync(organization); + + var twoFactorPolicy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true, + }; + await policyRepository.CreateAsync(twoFactorPolicy); + + var inviteLink = new OrganizationInviteLink + { + OrganizationId = organization.Id, + Invite = "opaque-invite-blob", + SupportsConfirmation = false, + }; + inviteLink.SetAllowedDomains(Array.Empty()); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return (organization, inviteLink); + } + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAsync( IdentityApplicationFactory factory) { @@ -483,6 +524,71 @@ public async Task RegistrationWithEmailVerification_WithMatchingOpenOrgInvite_Su Assert.NotNull(user); Assert.Equal(email, user.Email); Assert.Equal(name, user.Name); + + // Seeded org has no Require 2FA policy — user must not be initialized with 2FA providers. + Assert.Null(user.GetTwoFactorProviders()); + } + + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndTwoFactorPolicyEnabled_SeedsEmail2Fa( + [Required] string name, bool receiveMarketingEmails, + [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, + [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, + int kdfMemory, int kdfParallelism) + { + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + + var email = $"test+2fapolicy+{name}@email.com"; + var (_, inviteLink) = await SeedOrgWithInviteLinkAndTwoFactorPolicyAsync(localFactory); + + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + Assert.NotNull(localFactory.RegistrationTokens[email]); + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status200OK, finishCtx.Response.StatusCode); + + var database = localFactory.GetDatabaseContext(); + var user = await database.Users.SingleAsync(u => u.Email == email); + Assert.NotNull(user); + + var providers = user.GetTwoFactorProviders(); + Assert.NotNull(providers); + Assert.True(providers.TryGetValue(TwoFactorProviderType.Email, out var emailProvider)); + Assert.True(emailProvider!.Enabled); + Assert.Equal(email.ToLowerInvariant(), emailProvider.MetaData["Email"]?.ToString()); } [Theory, BitAutoData] From addcda5ddad4d2fae4444b864bfc3eb64183f51f Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 20:49:54 -0400 Subject: [PATCH 04/13] PM-41503 - Gate open-org-invite domain-block bypass on invite link's 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. --- .../IValidateOrganizationInviteLinkQuery.cs | 23 ++-- .../ValidateOrganizationInviteLinkQuery.cs | 13 ++- .../Implementations/RegisterUserCommand.cs | 2 +- ...VerificationEmailForRegistrationCommand.cs | 2 +- ...alidateOrganizationInviteLinkQueryTests.cs | 72 ++++++++++-- .../Registration/RegisterUserCommandTests.cs | 24 ++-- ...icationEmailForRegistrationCommandTests.cs | 14 +-- .../Controllers/AccountsControllerTests.cs | 103 ++++++++++++++++-- 8 files changed, 206 insertions(+), 47 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs index 64a5e0322869..b8c532ef2543 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs @@ -5,18 +5,23 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; public interface IValidateOrganizationInviteLinkQuery { /// - /// Validates that an open organization invite link is usable — narrower than - /// , 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 and valid. + /// Validates that an open organization invite link is usable for the given email — narrower + /// than , 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. /// /// The organization's ID (from the URL path). /// The public invite link code. + /// The registering user's email; checked against the link's AllowedDomains. /// - /// Void success if the link is valid; if the link - /// does not exist, the code does not match, or the organization is missing or disabled; - /// if the organization has the invite links feature - /// disabled. + /// Void success if the link is valid and admits the email; + /// if the link does not exist, the code does not match, or the organization is missing or + /// disabled; if the organization has the invite links + /// feature disabled; if the email's domain is not in + /// the link's AllowedDomains. /// - Task ValidateAsync(Guid organizationId, Guid code); + Task ValidateAsync(Guid organizationId, Guid code, string email); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs index dedf7353db3b..23b63bce400f 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Utilities; using Bit.Core.AdminConsole.Utilities.v2.Results; using Bit.Core.Repositories; using OneOf.Types; @@ -11,7 +12,7 @@ public class ValidateOrganizationInviteLinkQuery( IOrganizationRepository organizationRepository) : IValidateOrganizationInviteLinkQuery { - public async Task ValidateAsync(Guid organizationId, Guid code) + public async Task ValidateAsync(Guid organizationId, Guid code, string email) { var inviteLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId); if (inviteLink is null || !inviteLink.CodeMatches(code.ToString())) @@ -30,6 +31,16 @@ public async Task ValidateAsync(Guid organizationId, Guid code) return new InviteLinkNotAvailable(); } + // 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. + if (!InviteLinkDomainValidator.IsEmailDomainAllowed(email, inviteLink.GetAllowedDomains())) + { + return new EmailDomainNotAllowed(organization.DisplayName()); + } + return new None(); } } diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index c5978c4c9a01..1db74ad5fc14 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -296,7 +296,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOr ValidateOpenRegistrationAllowed(); var validationResult = await _validateOrganizationInviteLinkQuery.ValidateAsync( - openOrgInvite.OrganizationId, openOrgInvite.Code); + openOrgInvite.OrganizationId, openOrgInvite.Code, user.Email); if (validationResult.IsError) { throw new BadRequestException("Invalid or expired organization invite link."); diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 0aac90f4e741..fdb8b410269c 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs @@ -67,7 +67,7 @@ public SendVerificationEmailForRegistrationCommand( if (openOrgInvite is not null) { var validationResult = await _validateOrganizationInviteLinkQuery.ValidateAsync( - openOrgInvite.OrganizationId, openOrgInvite.Code); + openOrgInvite.OrganizationId, openOrgInvite.Code, email); if (validationResult.IsError) { throw new BadRequestException("Invalid or expired organization invite link."); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs index a18777ed925d..1ccfc6a90e29 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs @@ -14,7 +14,7 @@ namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; public class ValidateOrganizationInviteLinkQueryTests { [Theory, BitAutoData] - public async Task ValidateAsync_WithValidLink_Success( + public async Task ValidateAsync_WithValidLinkAndAllowedDomainEmail_Success( OrganizationInviteLink inviteLink, Organization organization, SutProvider sutProvider) @@ -24,13 +24,15 @@ public async Task ValidateAsync_WithValidLink_Success( organization.Enabled = true; organization.UseInviteLinks = true; inviteLink.Code = code.ToString(); + inviteLink.SetAllowedDomains(new[] { "example.com" }); + var email = "user@example.com"; sutProvider.GetDependency() .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); sutProvider.GetDependency() .GetByIdAsync(organization.Id).Returns(organization); - var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); Assert.True(result.IsSuccess); @@ -44,12 +46,13 @@ await sutProvider.GetDependency() public async Task ValidateAsync_InviteLinkNotFound_ReturnsInviteLinkNotFound( Guid organizationId, Guid code, + string email, SutProvider sutProvider) { sutProvider.GetDependency() .GetByOrganizationIdAsync(organizationId).ReturnsNull(); - var result = await sutProvider.Sut.ValidateAsync(organizationId, code); + var result = await sutProvider.Sut.ValidateAsync(organizationId, code, email); Assert.True(result.IsError); Assert.IsType(result.AsError); @@ -62,12 +65,13 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task ValidateAsync_CodeMismatch_ReturnsInviteLinkNotFound( OrganizationInviteLink inviteLink, + string email, SutProvider sutProvider) { sutProvider.GetDependency() .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); - var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, Guid.NewGuid()); + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, Guid.NewGuid(), email); Assert.True(result.IsError); Assert.IsType(result.AsError); @@ -80,6 +84,7 @@ await sutProvider.GetDependency() [Theory, BitAutoData] public async Task ValidateAsync_OrganizationNotFound_ReturnsInviteLinkNotFound( OrganizationInviteLink inviteLink, + string email, SutProvider sutProvider) { var code = Guid.NewGuid(); @@ -90,7 +95,7 @@ public async Task ValidateAsync_OrganizationNotFound_ReturnsInviteLinkNotFound( sutProvider.GetDependency() .GetByIdAsync(inviteLink.OrganizationId).ReturnsNull(); - var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); Assert.True(result.IsError); Assert.IsType(result.AsError); @@ -100,6 +105,7 @@ public async Task ValidateAsync_OrganizationNotFound_ReturnsInviteLinkNotFound( public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( OrganizationInviteLink inviteLink, Organization organization, + string email, SutProvider sutProvider) { var code = Guid.NewGuid(); @@ -112,7 +118,7 @@ public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( sutProvider.GetDependency() .GetByIdAsync(organization.Id).Returns(organization); - var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); Assert.True(result.IsError); Assert.IsType(result.AsError); @@ -122,6 +128,7 @@ public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( public async Task ValidateAsync_UseInviteLinksFalse_ReturnsInviteLinkNotAvailable( OrganizationInviteLink inviteLink, Organization organization, + string email, SutProvider sutProvider) { var code = Guid.NewGuid(); @@ -135,9 +142,60 @@ public async Task ValidateAsync_UseInviteLinksFalse_ReturnsInviteLinkNotAvailabl sutProvider.GetDependency() .GetByIdAsync(organization.Id).Returns(organization); - var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code); + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); Assert.True(result.IsError); Assert.IsType(result.AsError); } + + [Theory, BitAutoData] + public async Task ValidateAsync_EmailDomainNotInAllowedDomains_ReturnsEmailDomainNotAllowed( + OrganizationInviteLink inviteLink, + Organization organization, + SutProvider sutProvider) + { + var code = Guid.NewGuid(); + organization.Id = inviteLink.OrganizationId; + organization.Enabled = true; + organization.UseInviteLinks = true; + inviteLink.Code = code.ToString(); + inviteLink.SetAllowedDomains(new[] { "partner.com" }); + var email = "user@example.com"; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EmptyAllowedDomains_ReturnsEmailDomainNotAllowed( + OrganizationInviteLink inviteLink, + Organization organization, + SutProvider sutProvider) + { + // Empty AllowedDomains means the link admits no email domain (per InviteLinkDomainValidator). + var code = Guid.NewGuid(); + organization.Id = inviteLink.OrganizationId; + organization.Enabled = true; + organization.UseInviteLinks = true; + inviteLink.Code = code.ToString(); + inviteLink.SetAllowedDomains(Array.Empty()); + var email = "user@example.com"; + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + sutProvider.GetDependency() + .GetByIdAsync(organization.Id).Returns(organization); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, code, email); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } } diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 3f33303967c3..4d8ebe73eafb 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -723,7 +723,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ValidLin var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -766,7 +766,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidC var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new InviteLinkNotFound())); // Act & Assert @@ -790,7 +790,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksDis var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new InviteLinkNotAvailable())); // Act & Assert @@ -816,7 +816,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksEna var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); // Excluded-org path returns false; unfiltered path would return true. @@ -871,7 +871,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_OpenRegi // Short-circuit: registration guard must run before the invite validator and token check. await sutProvider.GetDependency() .DidNotReceiveWithAnyArgs() - .ValidateAsync(default, default); + .ValidateAsync(default, default, default!); sutProvider.GetDependency>() .DidNotReceiveWithAnyArgs() .TryUnprotect(default, out Arg.Any()); @@ -891,7 +891,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_InvalidT var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -930,7 +930,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_TwoFacto var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -995,7 +995,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_TwoFacto var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -1054,7 +1054,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_Succeeds var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organization.Id, code) + .ValidateAsync(organization.Id, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -1117,7 +1117,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_Succeeds var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organization.Id, code) + .ValidateAsync(organization.Id, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -1171,7 +1171,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_DomainBl var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); // The excluded-org filter still returns true — some OTHER org has claimed the domain. @@ -1204,7 +1204,7 @@ public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ComplexH var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organization.Id, Code = code }; sutProvider.GetDependency() - .ValidateAsync(organization.Id, code) + .ValidateAsync(organization.Id, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() diff --git a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index 9460c84ee435..b64e7c8351f2 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs @@ -343,7 +343,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenNewUserAndOpen .Returns(false); sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); var mockedToken = "token"; @@ -387,7 +387,7 @@ public async Task SendVerificationEmailForRegistrationCommand_WhenExistingUserAn .Returns(false); sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); // Act @@ -433,7 +433,7 @@ await sutProvider.GetDependency() await sutProvider.GetDependency() .DidNotReceive() - .ValidateAsync(Arg.Any(), Arg.Any()); + .ValidateAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Theory] @@ -457,7 +457,7 @@ public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Prov .DisableUserRegistration = false; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); sutProvider.GetDependency() @@ -487,7 +487,7 @@ public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Prov .DisableUserRegistration = false; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new InviteLinkNotFound())); // Act & Assert @@ -510,7 +510,7 @@ public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Prov .DisableUserRegistration = false; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new InviteLinkNotAvailable())); // Act & Assert @@ -540,7 +540,7 @@ public async Task SendVerificationEmailForRegistrationCommand_OpenOrgInvite_Prov .DisableUserRegistration = false; sutProvider.GetDependency() - .ValidateAsync(organizationId, code) + .ValidateAsync(organizationId, code, Arg.Any()) .Returns(new CommandResult(new None())); // Excluded-org path returns false; unfiltered path would return true. Verifies the excludeOrganizationId branch is taken. diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 46faff763742..b2e99255023d 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -186,7 +186,10 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+attacker+{name}@{claimedDomain}"; await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); - var (_, attackerInviteLink) = await SeedOrgWithInviteLinkAsync(localFactory); + // OrgB admits the attacker's email so the 400 must come from OrgA's block policy, not + // OrgB's own AllowedDomains — keeps this test focused on the exclusion-scoping guarantee. + var (_, attackerInviteLink) = await SeedOrgWithInviteLinkAsync( + localFactory, allowedDomains: new[] { claimedDomain }); var model = new RegisterSendVerificationEmailRequestModel { @@ -206,8 +209,41 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } + [Theory, BitAutoData] + public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_EmailDomainNotInAllowedDomains_ReturnsBadRequest(string name, bool receiveMarketingEmails) + { + // The registering email's domain is claimed by OrgA, but OrgA's invite link permits a + // different domain. Possession of the {orgId, code} alone must NOT grant the domain-block + // exclusion — the invite link would reject this email at accept time, so the exclusion + // must gate on the link's AllowedDomains as well. + var localFactory = new IdentityApplicationFactory(); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var permittedDomain = $"partner-{Guid.NewGuid():N}.example.com"; + var email = $"test+claimednotallowed+{name}@{claimedDomain}"; + var (_, inviteLink) = await SeedOrgWithClaimedDomainAndInviteLinkAsync( + localFactory, claimedDomain, allowedDomains: new[] { permittedDomain }); + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + + var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); + + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithClaimedDomainAndInviteLinkAsync( - IdentityApplicationFactory factory, string claimedDomain) + IdentityApplicationFactory factory, string claimedDomain, IEnumerable? allowedDomains = null) { var organizationRepository = factory.Services.GetRequiredService(); var organizationDomainRepository = factory.Services.GetRequiredService(); @@ -249,7 +285,7 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still Invite = "opaque-invite-blob", SupportsConfirmation = false, }; - inviteLink.SetAllowedDomains(new[] { claimedDomain }); + inviteLink.SetAllowedDomains(allowedDomains ?? new[] { claimedDomain }); inviteLink.SetNewId(); inviteLink.SetNewCode(); await organizationInviteLinkRepository.CreateAsync(inviteLink); @@ -258,7 +294,7 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still } private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAndTwoFactorPolicyAsync( - IdentityApplicationFactory factory) + IdentityApplicationFactory factory, IEnumerable? allowedDomains = null) { var organizationRepository = factory.Services.GetRequiredService(); var policyRepository = factory.Services.GetRequiredService(); @@ -289,7 +325,7 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still Invite = "opaque-invite-blob", SupportsConfirmation = false, }; - inviteLink.SetAllowedDomains(Array.Empty()); + inviteLink.SetAllowedDomains(allowedDomains ?? new[] { "email.com" }); inviteLink.SetNewId(); inviteLink.SetNewCode(); await organizationInviteLinkRepository.CreateAsync(inviteLink); @@ -298,7 +334,7 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still } private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAsync( - IdentityApplicationFactory factory) + IdentityApplicationFactory factory, IEnumerable? allowedDomains = null) { var organizationRepository = factory.Services.GetRequiredService(); var organizationInviteLinkRepository = factory.Services.GetRequiredService(); @@ -319,9 +355,8 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still OrganizationId = organization.Id, Invite = "opaque-invite-blob", SupportsConfirmation = false, - AllowedDomains = "[]", }; - inviteLink.SetAllowedDomains(Array.Empty()); + inviteLink.SetAllowedDomains(allowedDomains ?? Array.Empty()); inviteLink.SetNewId(); inviteLink.SetNewCode(); await organizationInviteLinkRepository.CreateAsync(inviteLink); @@ -591,6 +626,53 @@ public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndTwoFacto Assert.Equal(email.ToLowerInvariant(), emailProvider.MetaData["Email"]?.ToString()); } + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndEmailDomainNotInAllowedDomains_ReturnsBadRequest( + [Required] string name, [StringLength(1000), Required] string masterPasswordHash, + [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys) + { + // Mirror of the register-start AllowedDomains gap test at the register-finish endpoint: + // a bearer of an invite {orgId, code} whose AllowedDomains does not admit the email must + // not receive the domain-block exclusion when finishing registration either. + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + + var email = $"test+finishnotallowed+{name}@email.com"; + + // Register-start with no invite for an unclaimed email — yields a plain token. + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + Assert.NotNull(localFactory.RegistrationTokens[email]); + + // Seed an org whose invite permits only a different domain than the email uses. + var (_, inviteLink) = await SeedOrgWithInviteLinkAsync( + localFactory, allowedDomains: new[] { "different.example.com" }); + + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status400BadRequest, finishCtx.Response.StatusCode); + } + [Theory, BitAutoData] public async Task RegistrationWithEmailVerification_WithInvalidOpenOrgInvite_ReturnsBadRequest([Required] string name, [StringLength(1000), Required] string masterPasswordHash, [Required] string userSymmetricKey, @@ -645,7 +727,10 @@ public async Task RegistrationWithEmailVerification_WithDifferentOrgOpenOrgInvit var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+attackerfinish+{name}@{claimedDomain}"; var (_, orgAInvite) = await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); - var (_, orgBInvite) = await SeedOrgWithInviteLinkAsync(localFactory); + // OrgB admits the attacker's email so the 400 must come from OrgA's block policy, not + // OrgB's own AllowedDomains — keeps this test focused on the exclusion-scoping guarantee. + var (_, orgBInvite) = await SeedOrgWithInviteLinkAsync( + localFactory, allowedDomains: new[] { claimedDomain }); // Register-start with OrgA's invite so the claimed-domain block is bypassed and we receive a token. var sendReqModel = new RegisterSendVerificationEmailRequestModel From e81c36f4637fc1fb1c73e4407a388a8cdf9da2ce Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 21:03:26 -0400 Subject: [PATCH 05/13] PM-41533 - Delegate SetUserEmail2FaIfOrgPolicyEnabledAsync to the by-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. --- .../Implementations/RegisterUserCommand.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 1db74ad5fc14..510e7594348c 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -235,21 +235,7 @@ private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, U var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); if (orgUser != null) { - var twoFactorPolicy = await _policyQuery.RunAsync(orgUser.OrganizationId, - PolicyType.TwoFactorAuthentication); - if (twoFactorPolicy.Enabled) - { - user.SetTwoFactorProviders(new Dictionary - { - - [TwoFactorProviderType.Email] = new TwoFactorProvider - { - MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, - Enabled = true - } - }); - _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); - } + await SetUserEmail2FaIfOrgPolicyEnabledByOrgIdAsync(orgUser.OrganizationId, user); } return orgUser; } From 0472aeda59a885337fa2ac0c803c0510a15fc1d6 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 21:49:07 -0400 Subject: [PATCH 06/13] PM-41503 - Gate open-org-invite registration on the GenerateInviteLink feature flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Controllers/AccountsController.cs | 21 ++++ .../Controllers/AccountsControllerTests.cs | 40 +++++++ .../Controllers/AccountsControllerTests.cs | 106 ++++++++++++++++++ 3 files changed, 167 insertions(+) diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 20c35385a489..9cb997948f47 100644 --- a/src/Identity/Controllers/AccountsController.cs +++ b/src/Identity/Controllers/AccountsController.cs @@ -1,4 +1,5 @@ using System.Text; +using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts; @@ -17,6 +18,7 @@ using Bit.Identity.Models.Request.Accounts; using Bit.Identity.Models.Response.Accounts; using Bit.SharedWeb.Utilities; +using Bitwarden.Server.Sdk.Features; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -32,6 +34,7 @@ public class AccountsController : Controller private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly IFeatureService _featureService; private readonly byte[]? _defaultKdfHmacKey = null; internal static readonly List _defaultKdfResults = @@ -83,6 +86,7 @@ public AccountsController( IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand, ISendVerificationEmailForRegistrationCommand sendVerificationEmailForRegistrationCommand, IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + IFeatureService featureService, GlobalSettings globalSettings ) { @@ -92,6 +96,7 @@ GlobalSettings globalSettings _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; _sendVerificationEmailForRegistrationCommand = sendVerificationEmailForRegistrationCommand; _registrationEmailVerificationTokenDataFactory = registrationEmailVerificationTokenDataFactory; + _featureService = featureService; if (CoreHelpers.SettingHasValue(globalSettings.KdfDefaultHashKey)) { @@ -102,6 +107,8 @@ GlobalSettings globalSettings [HttpPost("register/send-verification-email")] public async Task PostRegisterSendVerificationEmail([FromBody] RegisterSendVerificationEmailRequestModel model) { + GuardOpenOrgInviteFeatureEnabled(model.OpenOrgInvite); + var token = await _sendVerificationEmailForRegistrationCommand.Run(model.Email, model.Name, model.ReceiveMarketingEmails, model.FromMarketing, model.OpenOrgInvite); @@ -113,6 +120,18 @@ public async Task PostRegisterSendVerificationEmail([FromBody] Re return NoContent(); } + /// + /// Mirrors [RequireFeature(FeatureFlagKeys.GenerateInviteLink)] on the other invite-link + /// surfaces — throws when the flag is off. + /// + private void GuardOpenOrgInviteFeatureEnabled(OpenOrgInviteRequestModel? openOrgInvite) + { + if (openOrgInvite is not null && !_featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink)) + { + throw new FeatureUnavailableException(); + } + } + [HttpPost("register/verification-email-clicked")] public async Task PostRegisterVerificationEmailClicked([FromBody] RegisterVerificationEmailClickedRequestModel model) { @@ -134,6 +153,8 @@ public async Task PostRegisterVerificationEmailClicked([FromBody] [HttpPost("register/finish")] public async Task PostRegisterFinish([FromBody] RegisterFinishRequestModel model) { + GuardOpenOrgInviteFeatureEnabled(model.OpenOrgInvite); + var registerFinishData = model.ToData(); var user = model.ToUser(registerFinishData.IsV2Encryption()); diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index b2e99255023d..c5d5bc0a7133 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Text; using System.Text.Json; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; @@ -29,6 +30,9 @@ namespace Bit.Identity.IntegrationTest.Controllers; public class AccountsControllerTests : IClassFixture { + private const string GenerateInviteLinkFlagSettingKey = + $"globalSettings:launchDarkly:flagValues:{FeatureFlagKeys.GenerateInviteLink}"; + private readonly IdentityApplicationFactory _factory; public AccountsControllerTests(IdentityApplicationFactory factory) @@ -148,11 +152,40 @@ public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_OversizedS Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } + [Theory, BitAutoData] + public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ReturnsNotFound(string name, bool receiveMarketingEmails) + { + // With the flag turned off, the endpoint must refuse to honor the OpenOrgInvite payload + // — mirroring [RequireFeature] on the sibling invite-link surfaces (→ 404). The other + // invite-link OpenOrgInvite tests in this file explicitly turn the flag ON; this one is + // the sole flag-OFF integration case. + var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "false"); + + var model = new RegisterSendVerificationEmailRequestModel + { + Email = $"test+flagoff+{name}@example.com", + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = Guid.NewGuid(), + Code = Guid.NewGuid(), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + + var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); + + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + } + [Theory, BitAutoData] public async Task PostRegisterSendEmailVerification_WithMatchingOrgInvite_BypassesClaimedDomainBlock(string name, bool receiveMarketingEmails) { // Isolated factory to keep the seeded org/policy/domain out of the shared fixture. var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+claimed+{name}@{claimedDomain}"; @@ -182,6 +215,7 @@ public async Task PostRegisterSendEmailVerification_WithDifferentOrgInvite_Still // Attacker scenario: sender's invite belongs to OrgB, but the email's domain is claimed by OrgA. // OrgA's block policy must still fire because the exclusion is scoped to OrgB, not OrgA. var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+attacker+{name}@{claimedDomain}"; @@ -217,6 +251,7 @@ public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_EmailDomai // exclusion — the invite link would reject this email at accept time, so the exclusion // must gate on the link's AllowedDomains as well. var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var permittedDomain = $"partner-{Guid.NewGuid():N}.example.com"; @@ -509,6 +544,7 @@ public async Task RegistrationWithEmailVerification_WithMatchingOpenOrgInvite_Su { userAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+claimedfinish+{name}@{claimedDomain}"; @@ -573,6 +609,7 @@ public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndTwoFacto { userAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var email = $"test+2fapolicy+{name}@email.com"; var (_, inviteLink) = await SeedOrgWithInviteLinkAndTwoFactorPolicyAsync(localFactory); @@ -636,6 +673,7 @@ public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndEmailDom // not receive the domain-block exclusion when finishing registration either. userAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var email = $"test+finishnotallowed+{name}@email.com"; @@ -680,6 +718,7 @@ public async Task RegistrationWithEmailVerification_WithInvalidOpenOrgInvite_Ret { userAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var email = $"test+register+badfinishlink+{name}@email.com"; @@ -723,6 +762,7 @@ public async Task RegistrationWithEmailVerification_WithDifferentOrgOpenOrgInvit // block policy. The domain-block check must still exclude only OrgB, so OrgA's policy fires → 400. userAsymmetricKeys.AccountKeys = null; var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; var email = $"test+attackerfinish+{name}@{claimedDomain}"; diff --git a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs index 4baab40be2ee..eec50db7ba9c 100644 --- a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Reflection; using System.Text; +using Bit.Core; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration; @@ -19,6 +20,7 @@ using Bit.Identity.Controllers; using Bit.Identity.Models.Request.Accounts; using Bit.Test.Common.AutoFixture.Attributes; +using Bitwarden.Server.Sdk.Features; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using NSubstitute; @@ -38,6 +40,7 @@ public class AccountsControllerTests : IDisposable private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly ISendVerificationEmailForRegistrationCommand _sendVerificationEmailForRegistrationCommand; private readonly IDataProtectorTokenFactory _registrationEmailVerificationTokenDataFactory; + private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; @@ -49,6 +52,7 @@ public AccountsControllerTests() _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); _sendVerificationEmailForRegistrationCommand = Substitute.For(); _registrationEmailVerificationTokenDataFactory = Substitute.For>(); + _featureService = Substitute.For(); _globalSettings = Substitute.For(); _sut = new AccountsController( @@ -58,6 +62,7 @@ public AccountsControllerTests() _getWebAuthnLoginCredentialAssertionOptionsCommand, _sendVerificationEmailForRegistrationCommand, _registrationEmailVerificationTokenDataFactory, + _featureService, _globalSettings ); } @@ -328,6 +333,7 @@ public async Task PostRegisterSendEmailVerification_ForwardsOpenOrgInvite( string email, string name, bool receiveMarketingEmails, Guid organizationId, Guid code) { // Arrange + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(true); var openOrgInvite = new RegisterStartOpenOrgInviteRequestModel { OrganizationId = organizationId, @@ -350,6 +356,58 @@ await _sendVerificationEmailForRegistrationCommand.Received(1) .Run(email, name, receiveMarketingEmails, null, openOrgInvite); } + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ThrowsBadRequest( + string email, string name, bool receiveMarketingEmails, Guid organizationId, Guid code) + { + // Arrange + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(false); + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = organizationId, + Code = code, + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + + // Act & Assert — mirrors [RequireFeature] behavior on the other invite-link surfaces (→ 404). + await Assert.ThrowsAsync(() => + _sut.PostRegisterSendVerificationEmail(model)); + + // Short-circuit: the underlying command must not be invoked when the flag gates the payload out. + await _sendVerificationEmailForRegistrationCommand + .DidNotReceiveWithAnyArgs() + .Run(default!, default, default, default, default); + } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WithoutOpenOrgInvite_DoesNotConsultFeatureFlag( + string email, string name, bool receiveMarketingEmails) + { + // Arrange — flag is off, but the payload has no OpenOrgInvite, so vanilla registration proceeds. + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(false); + var model = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + }; + + // Act + await _sut.PostRegisterSendVerificationEmail(model); + + // Assert + await _sendVerificationEmailForRegistrationCommand.Received(1) + .Run(email, name, receiveMarketingEmails, null); + } + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomizeAttribute] public async Task PostRegisterFinish_WhenGivenOrgInvite_ShouldRegisterUser( string email, string masterPasswordHash, string orgInviteToken, Guid organizationUserId, string userSymmetricKey, @@ -615,6 +673,7 @@ public async Task PostRegisterFinish_EmailVerification_ForwardsOpenOrgInvite( AccountKeysRequestModel accountKeys, Guid organizationId, Guid code) { // Arrange + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(true); var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; var kdfModel = new KdfRequestModel @@ -660,6 +719,53 @@ await _registerUserCommand.DidNotReceive().RegisterUserViaEmailVerificationToken Arg.Any(), Arg.Any(), Arg.Any()); } + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] + public async Task PostRegisterFinish_WithOpenOrgInviteAndFeatureFlagOff_ThrowsBadRequest( + string email, string emailVerificationToken, string userSymmetricKey, string masterPasswordHash, + AccountKeysRequestModel accountKeys, Guid organizationId, Guid code) + { + // Arrange + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(false); + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + var kdfModel = new KdfRequestModel + { + KdfType = KdfType.Argon2id, + Iterations = KdfConstants.ARGON2_ITERATIONS.Default, + Memory = KdfConstants.ARGON2_MEMORY.Default, + Parallelism = KdfConstants.ARGON2_PARALLELISM.Default, + }; + + var model = new RegisterFinishRequestModel + { + Email = email, + EmailVerificationToken = emailVerificationToken, + OpenOrgInvite = openOrgInvite, + MasterPasswordAuthentication = new MasterPasswordAuthenticationDataRequestModel + { + MasterPasswordAuthenticationHash = masterPasswordHash, + Kdf = kdfModel, + Salt = email.ToLowerInvariant().Trim(), + }, + MasterPasswordUnlock = new MasterPasswordUnlockDataRequestModel + { + Kdf = kdfModel, + MasterKeyWrappedUserKey = userSymmetricKey, + Salt = email.ToLowerInvariant().Trim(), + }, + AccountKeys = accountKeys, + }; + + // Act & Assert — mirrors [RequireFeature] behavior on the other invite-link surfaces (→ 404). + await Assert.ThrowsAsync(() => _sut.PostRegisterFinish(model)); + + // Short-circuit: neither command variant may be invoked when the flag gates the payload out. + await _registerUserCommand.DidNotReceiveWithAnyArgs().RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + default!, default!, default!, default!); + await _registerUserCommand.DidNotReceiveWithAnyArgs().RegisterUserViaEmailVerificationToken( + default!, default!, default!); + } + [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] public async Task PostRegisterFinish_WhenGivenEmailVerificationTokenDuplicateUser_ThrowsBadRequestException( string email, string masterPasswordHash, string emailVerificationToken, string userSymmetricKey, From bfa5d4f86f6e5c2745470ad17e5975da4ca59614 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 21:55:05 -0400 Subject: [PATCH 07/13] PM-41503 - Opt OpenOrgInvite out of RegisterFinishRequestModel AutoFixture 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. --- .../Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs index 73e5a3bca4bb..0d3ac8398a05 100644 --- a/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs +++ b/test/Core.Test/Auth/AutoFixture/RegisterFinishRequestModelFixtures.cs @@ -31,6 +31,7 @@ public void Customize(IFixture fixture) .With(o => o.OrgSponsoredFreeFamilyPlanToken, OrgSponsoredFreeFamilyPlanToken) .With(o => o.AcceptEmergencyAccessInviteToken, AcceptEmergencyAccessInviteToken) .With(o => o.ProviderInviteToken, ProviderInviteToken) + .Without(o => o.OpenOrgInvite) .Without(o => o.MasterPasswordAuthentication) .Without(o => o.MasterPasswordUnlock)); } From e23fb663911516de2daffb168e02c23f5b4292f0 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 22:14:42 -0400 Subject: [PATCH 08/13] PM-41503 - Enable GenerateInviteLink flag in InvalidLink integration 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. --- .../Controllers/AccountsControllerTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index c5d5bc0a7133..558490445882 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -107,6 +107,9 @@ public async Task PostRegisterSendEmailVerification_WhenGivenNewOrExistingUser__ public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_InvalidLink_ReturnsBadRequest(string name, bool receiveMarketingEmails) { // OpenOrgInvite payload without a matching invite link on the org is rejected. + var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + var email = $"test+register+badlink+{name}@email.com"; var model = new RegisterSendVerificationEmailRequestModel @@ -122,7 +125,7 @@ public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_InvalidLin }, }; - var context = await _factory.PostRegisterSendEmailVerificationAsync(model); + var context = await localFactory.PostRegisterSendEmailVerificationAsync(model); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } From b4165a40d844c72c46fb42fddfc453d84c1e44d0 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Thu, 6 Aug 2026 22:24:00 -0400 Subject: [PATCH 09/13] PM-41503 - Rename flag-off tests to reflect FeatureUnavailable status 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. --- .../Identity.Test/Auth/Controllers/AccountsControllerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs index eec50db7ba9c..adf0cd4e4474 100644 --- a/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Identity.Test/Auth/Controllers/AccountsControllerTests.cs @@ -358,7 +358,7 @@ await _sendVerificationEmailForRegistrationCommand.Received(1) [Theory] [BitAutoData] - public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ThrowsBadRequest( + public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ThrowsFeatureUnavailable( string email, string name, bool receiveMarketingEmails, Guid organizationId, Guid code) { // Arrange @@ -720,7 +720,7 @@ await _registerUserCommand.DidNotReceive().RegisterUserViaEmailVerificationToken } [Theory, BitAutoData, SignatureKeyPairRequestModelCustomize] - public async Task PostRegisterFinish_WithOpenOrgInviteAndFeatureFlagOff_ThrowsBadRequest( + public async Task PostRegisterFinish_WithOpenOrgInviteAndFeatureFlagOff_ThrowsFeatureUnavailable( string email, string emailVerificationToken, string userSymmetricKey, string masterPasswordHash, AccountKeysRequestModel accountKeys, Guid organizationId, Guid code) { From 4a56ca2d7ee482ff6025e99899592c635c8aa170 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 7 Aug 2026 11:07:50 -0400 Subject: [PATCH 10/13] PM-41503 - Consolidate ValidateOrganizationInviteLinkQuery per review feedback Delete the sibling ValidateOrganizationInviteLinkEmailDomainQuery and rewire its sole caller (OrganizationInviteLinksController.ValidateEmailDomain) to the consolidated query with a controller-level mapping that preserves the {IsAllowed: bool} wire contract. Also tighten the interface XML doc, drop the caller-oriented comment on the domain check, add 4 integration tests for ValidateEmailDomain covering the disallowed-email / mismatched-code / org-disabled / UseInviteLinks-off paths, and pin UseInviteLinks=true in the OrganizationDisabled unit test to keep the "disabled trumps UseInviteLinks" ordering unambiguous. --- .../OrganizationInviteLinksController.cs | 16 ++- ...eOrganizationInviteLinkEmailDomainQuery.cs | 12 -- .../IValidateOrganizationInviteLinkQuery.cs | 17 +-- ...eOrganizationInviteLinkEmailDomainQuery.cs | 22 --- .../ValidateOrganizationInviteLinkQuery.cs | 5 - ...OrganizationServiceCollectionExtensions.cs | 1 - .../OrganizationInviteLinksControllerTests.cs | 132 ++++++++++++++++++ ...nizationInviteLinkEmailDomainQueryTests.cs | 85 ----------- ...alidateOrganizationInviteLinkQueryTests.cs | 2 + 9 files changed, 151 insertions(+), 141 deletions(-) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkEmailDomainQuery.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQuery.cs delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQueryTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs index e4c2902b9f7e..46ebcd6b6fd0 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationInviteLinksController.cs @@ -5,6 +5,7 @@ using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; using Bit.Core; +using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -23,7 +24,7 @@ public class OrganizationInviteLinksController( IUpdateInviteSupportConfirmCommand updateInviteSupportConfirmCommand, IDeleteOrganizationInviteLinkCommand deleteOrganizationInviteLinkCommand, IRefreshOrganizationInviteLinkCommand refreshOrganizationInviteLinkCommand, - IValidateOrganizationInviteLinkEmailDomainQuery validateOrganizationInviteLinkEmailDomainQuery, + IValidateOrganizationInviteLinkQuery validateOrganizationInviteLinkQuery, IGetOrganizationInviteLinkPoliciesQuery getOrganizationInviteLinkPoliciesQuery) : BaseAdminConsoleController { @@ -62,10 +63,17 @@ public async Task GetPolicies([FromBody] GetOrganizationInviteLinkPolic public async Task ValidateEmailDomain( [FromBody] OrganizationInviteLinkValidateEmailDomainRequestModel model) { - var result = await validateOrganizationInviteLinkEmailDomainQuery.ValidateAsync(model.OrganizationId, model.Code, model.Email); + var result = await validateOrganizationInviteLinkQuery.ValidateAsync(model.OrganizationId, model.Code, model.Email); - return Handle(result, isAllowed => - TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(isAllowed))); + // 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))); } [HttpGet("")] diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkEmailDomainQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkEmailDomainQuery.cs deleted file mode 100644 index 1f898b16538a..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkEmailDomainQuery.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Bit.Core.AdminConsole.Utilities.v2.Results; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; - -public interface IValidateOrganizationInviteLinkEmailDomainQuery -{ - /// - /// Returns whether the email's domain is allowed by the invite link, - /// or an error if the invite link does not exist or the code does not match. - /// - Task> ValidateAsync(Guid organizationId, Guid code, string email); -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs index b8c532ef2543..201e3b96fdf8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs @@ -5,23 +5,16 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; public interface IValidateOrganizationInviteLinkQuery { /// - /// Validates that an open organization invite link is usable for the given email — narrower - /// than , 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. + /// 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. /// /// The organization's ID (from the URL path). /// The public invite link code. /// The registering user's email; checked against the link's AllowedDomains. /// - /// Void success if the link is valid and admits the email; - /// if the link does not exist, the code does not match, or the organization is missing or - /// disabled; if the organization has the invite links - /// feature disabled; if the email's domain is not in - /// the link's AllowedDomains. + /// 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. /// Task ValidateAsync(Guid organizationId, Guid code, string email); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQuery.cs deleted file mode 100644 index c1dbeeaf56e8..000000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQuery.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.AdminConsole.Utilities; -using Bit.Core.AdminConsole.Utilities.v2.Results; - -namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; - -public class ValidateOrganizationInviteLinkEmailDomainQuery( - IOrganizationInviteLinkRepository organizationInviteLinkRepository) - : IValidateOrganizationInviteLinkEmailDomainQuery -{ - public async Task> ValidateAsync(Guid organizationId, Guid code, string email) - { - var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId); - if (link is null || !link.CodeMatches(code.ToString())) - { - return new InviteLinkNotFound(); - } - - return InviteLinkDomainValidator.IsEmailDomainAllowed(email, link.GetAllowedDomains()); - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs index 23b63bce400f..25638132dfe8 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs @@ -31,11 +31,6 @@ public async Task ValidateAsync(Guid organizationId, Guid code, s return new InviteLinkNotAvailable(); } - // 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. if (!InviteLinkDomainValidator.IsEmailDomainAllowed(email, inviteLink.GetAllowedDomains())) { return new EmailDomainNotAllowed(organization.DisplayName()); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 7743bd7e99e3..88f631100866 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -208,7 +208,6 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs index 7cee3f8e101e..7d66a43abcde 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationInviteLinksControllerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; using Bit.Core.Services; using NSubstitute; using Xunit; @@ -99,6 +100,137 @@ public async Task ValidateEmailDomain_WithAllowedEmail_ReturnsIsAllowedTrue() Assert.True(result.IsAllowed); } + [Fact] + public async Task ValidateEmailDomain_WithDisallowedEmail_ReturnsIsAllowedFalse() + { + // EmailDomainNotAllowed must map to a 200 OK with IsAllowed: false so that clients can + // surface a targeted UX message rather than treating the mismatch as an error. + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + Invite = _invite, + SupportsConfirmation = false, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + var validateRequest = new OrganizationInviteLinkValidateEmailDomainRequestModel + { + OrganizationId = _organization.Id, + Code = created.Code, + Email = "user@other.com", + }; + using var anonymousClient = _factory.CreateClient(); + var validateResponse = await anonymousClient.PostAsJsonAsync( + "/organizations/invite-link/validate-email-domain", validateRequest); + + Assert.Equal(HttpStatusCode.OK, validateResponse.StatusCode); + var result = await validateResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(result); + Assert.False(result.IsAllowed); + } + + [Fact] + public async Task ValidateEmailDomain_WithMismatchedCode_ReturnsNotFound() + { + // Non-domain failures must continue to surface as errors (via Handle) instead of being + // silently converted to IsAllowed:true — the disallowed-domain fallthrough is scoped to + // EmailDomainNotAllowed only. + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + Invite = _invite, + SupportsConfirmation = false, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var validateRequest = new OrganizationInviteLinkValidateEmailDomainRequestModel + { + OrganizationId = _organization.Id, + Code = Guid.NewGuid(), + Email = "user@acme.com", + }; + using var anonymousClient = _factory.CreateClient(); + var validateResponse = await anonymousClient.PostAsJsonAsync( + "/organizations/invite-link/validate-email-domain", validateRequest); + + Assert.Equal(HttpStatusCode.NotFound, validateResponse.StatusCode); + } + + [Fact] + public async Task ValidateEmailDomain_WithOrgDisabled_ReturnsNotFound() + { + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + Invite = _invite, + SupportsConfirmation = false, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + // Disable the org after creating the link so the validate call trips the Enabled=false branch. + var organizationRepository = _factory.Services.GetRequiredService(); + _organization.Enabled = false; + await organizationRepository.ReplaceAsync(_organization); + + var validateRequest = new OrganizationInviteLinkValidateEmailDomainRequestModel + { + OrganizationId = _organization.Id, + Code = created.Code, + Email = "user@acme.com", + }; + using var anonymousClient = _factory.CreateClient(); + var validateResponse = await anonymousClient.PostAsJsonAsync( + "/organizations/invite-link/validate-email-domain", validateRequest); + + Assert.Equal(HttpStatusCode.NotFound, validateResponse.StatusCode); + } + + [Fact] + public async Task ValidateEmailDomain_WithUseInviteLinksOff_ReturnsBadRequest() + { + var createRequest = new CreateOrganizationInviteLinkRequestModel + { + AllowedDomains = ["acme.com"], + Invite = _invite, + SupportsConfirmation = false, + }; + var createResponse = await _client.PostAsJsonAsync( + $"/organizations/{_organization.Id}/invite-link", createRequest); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var created = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + // Turn off the invite-links entitlement so the validate call trips the InviteLinkNotAvailable branch. + var organizationRepository = _factory.Services.GetRequiredService(); + _organization.UseInviteLinks = false; + await organizationRepository.ReplaceAsync(_organization); + + var validateRequest = new OrganizationInviteLinkValidateEmailDomainRequestModel + { + OrganizationId = _organization.Id, + Code = created.Code, + Email = "user@acme.com", + }; + using var anonymousClient = _factory.CreateClient(); + var validateResponse = await anonymousClient.PostAsJsonAsync( + "/organizations/invite-link/validate-email-domain", validateRequest); + + Assert.Equal(HttpStatusCode.BadRequest, validateResponse.StatusCode); + } + [Fact] public async Task CreateThenGet_AsOwner_ReturnsCreatedAndOk() { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQueryTests.cs deleted file mode 100644 index d80c7f2cdde7..000000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkEmailDomainQueryTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; -using Bit.Core.AdminConsole.Repositories; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.InviteLinks; - -[SutProviderCustomize] -public class ValidateOrganizationInviteLinkEmailDomainQueryTests -{ - [Theory, BitAutoData] - public async Task ValidateAsync_WhenLinkNotFound_ReturnsNotFoundError( - Guid organizationId, - Guid code, - string email, - SutProvider sutProvider) - { - sutProvider.GetDependency() - .GetByOrganizationIdAsync(organizationId) - .Returns((OrganizationInviteLink?)null); - - var result = await sutProvider.Sut.ValidateAsync(organizationId, code, email); - - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_WhenCodeMismatch_ReturnsNotFoundError( - OrganizationInviteLink link, - SutProvider sutProvider) - { - link.SetAllowedDomains(["acme.com"]); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(link.OrganizationId) - .Returns(link); - - var result = await sutProvider.Sut.ValidateAsync(link.OrganizationId, Guid.NewGuid(), "user@acme.com"); - - Assert.True(result.IsError); - Assert.IsType(result.AsError); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_WhenEmailDomainMatches_ReturnsTrue( - OrganizationInviteLink link, - SutProvider sutProvider) - { - var code = Guid.NewGuid(); - link.Code = code.ToString(); - link.SetAllowedDomains(["acme.com"]); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(link.OrganizationId) - .Returns(link); - - var result = await sutProvider.Sut.ValidateAsync(link.OrganizationId, code, "user@acme.com"); - - Assert.True(result.IsSuccess); - Assert.True(result.AsSuccess); - } - - [Theory, BitAutoData] - public async Task ValidateAsync_WhenEmailDomainDoesNotMatch_ReturnsFalse( - OrganizationInviteLink link, - SutProvider sutProvider) - { - var code = Guid.NewGuid(); - link.Code = code.ToString(); - link.SetAllowedDomains(["acme.com"]); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(link.OrganizationId) - .Returns(link); - - var result = await sutProvider.Sut.ValidateAsync(link.OrganizationId, code, "user@other.com"); - - Assert.True(result.IsSuccess); - Assert.False(result.AsSuccess); - } -} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs index 1ccfc6a90e29..ecd7090c1743 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs @@ -111,6 +111,8 @@ public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( var code = Guid.NewGuid(); organization.Id = inviteLink.OrganizationId; organization.Enabled = false; + // Explicit so this test pins "disabled trumps UseInviteLinks" independent of autofixture defaults. + organization.UseInviteLinks = true; inviteLink.Code = code.ToString(); sutProvider.GetDependency() From 67427030151f388844039b2d48ae63ea0c924bfe Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 7 Aug 2026 11:21:39 -0400 Subject: [PATCH 11/13] PM-41503 - Drop redundant file-level #nullable enable pragma Repo-level Directory.Build.props already sets enable for non-test projects, so the file-level pragma is a no-op. --- .../Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs | 3 +-- .../Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs index 9c9a1a2a78b0..df35ce65e40b 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Auth.Models.Api.Request.Accounts; diff --git a/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs index 314c8eb03f9f..93132a529534 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Core.Auth.Models.Api.Request.Accounts; From 80727c3f00d17a7f263efc433a5172257241f778 Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 7 Aug 2026 11:28:49 -0400 Subject: [PATCH 12/13] PM-41503 - Drop redundant [Required] attribute on Guid properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Required] on a non-nullable value type is a no-op — the property cannot be null and defaults to Guid.Empty when the JSON field is missing, so Validator.TryValidateObject reports PASS in every case that ends with a materialized instance. Empirical probing (System.Text.Json + DataAnnotations.Validator) confirmed that only the C# `required` keyword catches a missing field on a Guid property, by throwing at the deserializer boundary. `[Required]` on the sibling `SealedOpenOrgInviteData` string is retained: that field has no format validator and `[Required]` catches the explicit-null / empty-string cases that the `required` keyword alone silently allows through. --- .../Api/Request/Accounts/OpenOrgInviteRequestModel.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs index df35ce65e40b..333d5da2c560 100644 --- a/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs +++ b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs @@ -1,6 +1,4 @@ -using System.ComponentModel.DataAnnotations; - -namespace Bit.Core.Auth.Models.Api.Request.Accounts; +namespace Bit.Core.Auth.Models.Api.Request.Accounts; /// /// Identifying key for an open organization invite link: the target organization and the @@ -9,9 +7,7 @@ namespace Bit.Core.Auth.Models.Api.Request.Accounts; /// public class OpenOrgInviteRequestModel { - [Required] public required Guid OrganizationId { get; set; } - [Required] public required Guid Code { get; set; } } From 684df1da4e8247be1c62452c2d69d8477c41d41a Mon Sep 17 00:00:00 2001 From: Jared Snider Date: Fri, 7 Aug 2026 12:50:12 -0400 Subject: [PATCH 13/13] PM-41503 - Add claimed-domain-plus-2FA-policy interaction coverage Prior tests exercised the claimed-domain bypass and the Email 2FA seeding in isolation but never for a single org that has both policies enabled. Add one unit test in RegisterUserCommandTests and one full register-start + register-finish integration test in AccountsControllerTests, plus a SeedOrgWithClaimedDomainAndTwoFactorPolicyAndInviteLinkAsync helper, so a future regression in either path is caught in combination. --- .../Registration/RegisterUserCommandTests.cs | 66 +++++++++ .../Controllers/AccountsControllerTests.cs | 132 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index 4d8ebe73eafb..f4d5e39e1982 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -1267,6 +1267,72 @@ await sutProvider.GetDependency() .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); } + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_ClaimedDomainBypassedAnd2FaPolicyEnabled_SeedsEmail2Fa( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, true)] PolicyStatus policy) + { + // Arrange — an email on a domain claimed by the invite's own org, plus Require-2FA policy on + // that same org. Both fixes must fire together: the domain-block exclusion allows the user + // to register at all, and the 2FA policy check must still seed Email 2FA before create. + user.Email = "user@claimed.example.com"; + user.TwoFactorProviders = null; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code, Arg.Any()) + .Returns(new CommandResult(new None())); + + // Domain is globally claimed; only the invite's org exclusion unblocks it. If the code + // failed to pass the excludeOrgId, this test would trip the block and throw. + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), Arg.Is(g => g == null)) + .Returns(true); + sutProvider.GetDependency() + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organizationId) + .Returns(false); + + sutProvider.GetDependency>() + .TryUnprotect(emailVerificationToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = new RegistrationEmailVerificationTokenable(user.Email, user.Name, receiveMarketingMaterials); + return true; + }); + + sutProvider.GetDependency() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + string? twoFactorProvidersAtCreate = null; + sutProvider.GetDependency() + .CreateUserAsync(Arg.Do(u => twoFactorProvidersAtCreate = u.TwoFactorProviders), registerFinishData) + .Returns(IdentityResult.Success); + + // Act + var result = await sutProvider.Sut.RegisterUserViaEmailVerificationTokenAndOpenOrgInvite( + user, registerFinishData, emailVerificationToken, openOrgInvite); + + // Assert + Assert.True(result.Succeeded); + + // Domain check ran with the invite's org as the exclusion — proves the bypass path was taken. + await sutProvider.GetDependency() + .Received(1) + .HasVerifiedDomainWithBlockClaimedDomainPolicyAsync(Arg.Any(), organizationId); + + // 2FA policy consulted and Email 2FA seeded before create. + await sutProvider.GetDependency() + .Received(1) + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication); + sutProvider.GetDependency() + .Received(1) + .SetTwoFactorProvider(user, TwoFactorProviderType.Email); + Assert.NotNull(twoFactorProvidersAtCreate); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserViaOrganizationSponsoredFreeFamilyPlanInviteToken tests // ----------------------------------------------------------------------------------------------- diff --git a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs index 558490445882..6d59d0f1254c 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -371,6 +371,65 @@ public async Task PostRegisterSendEmailVerification_WithOpenOrgInvite_EmailDomai return (organization, inviteLink); } + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithClaimedDomainAndTwoFactorPolicyAndInviteLinkAsync( + IdentityApplicationFactory factory, string claimedDomain, IEnumerable? allowedDomains = null) + { + var organizationRepository = factory.Services.GetRequiredService(); + var organizationDomainRepository = factory.Services.GetRequiredService(); + var policyRepository = factory.Services.GetRequiredService(); + var organizationInviteLinkRepository = factory.Services.GetRequiredService(); + + var organization = new Organization + { + Name = $"ClaimedDomain2FaOrg-{Guid.NewGuid():N}", + BillingEmail = $"billing+{Guid.NewGuid():N}@example.com", + Plan = "Enterprise", + Enabled = true, + UsePolicies = true, + UseOrganizationDomains = true, + UseInviteLinks = true, + }; + organization = await organizationRepository.CreateAsync(organization); + + var domain = new OrganizationDomain + { + OrganizationId = organization.Id, + DomainName = claimedDomain, + Txt = "bw-test", + }; + domain.SetVerifiedDate(); + await organizationDomainRepository.CreateAsync(domain); + + var domainBlockPolicy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.BlockClaimedDomainAccountCreation, + Enabled = true, + }; + await policyRepository.CreateAsync(domainBlockPolicy); + + var twoFactorPolicy = new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Enabled = true, + }; + await policyRepository.CreateAsync(twoFactorPolicy); + + var inviteLink = new OrganizationInviteLink + { + OrganizationId = organization.Id, + Invite = "opaque-invite-blob", + SupportsConfirmation = false, + }; + inviteLink.SetAllowedDomains(allowedDomains ?? new[] { claimedDomain }); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return (organization, inviteLink); + } + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAsync( IdentityApplicationFactory factory, IEnumerable? allowedDomains = null) { @@ -666,6 +725,79 @@ public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndTwoFacto Assert.Equal(email.ToLowerInvariant(), emailProvider.MetaData["Email"]?.ToString()); } + [Theory, BitAutoData] + public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndClaimedDomainAndTwoFactorPolicy_BypassesDomainAndSeedsEmail2Fa( + [Required] string name, bool receiveMarketingEmails, + [StringLength(1000), Required] string masterPasswordHash, [StringLength(50)] string masterPasswordHint, + [Required] string userSymmetricKey, [Required] KeysRequestModel userAsymmetricKeys, + int kdfMemory, int kdfParallelism) + { + // Exercises the interaction: one org has BOTH a claimed-domain block AND a Require-2FA + // policy. Registering via that org's open invite must (a) skip the domain block because + // the invite matches the claiming org and (b) still seed Email 2FA before the user row + // is persisted. Neither fix in isolation covers this cross-feature path. + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+bothpolicies+{name}@{claimedDomain}"; + var (_, inviteLink) = await SeedOrgWithClaimedDomainAndTwoFactorPolicyAndInviteLinkAsync(localFactory, claimedDomain); + + // Register-start with the matching invite — bypasses claimed-domain block. + var sendReqModel = new RegisterSendVerificationEmailRequestModel + { + Email = email, + Name = name, + ReceiveMarketingEmails = receiveMarketingEmails, + OpenOrgInvite = new RegisterStartOpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + SealedOpenOrgInviteData = "opaque-base64url-blob", + }, + }; + var sendCtx = await localFactory.PostRegisterSendEmailVerificationAsync(sendReqModel); + Assert.Equal(StatusCodes.Status204NoContent, sendCtx.Response.StatusCode); + Assert.NotNull(localFactory.RegistrationTokens[email]); + + // Register-finish — the domain-block check must exclude this org AND the 2FA policy must fire. + var registerFinishReqModel = new RegisterFinishRequestModel + { + Email = email, + MasterPasswordHash = masterPasswordHash, + MasterPasswordHint = masterPasswordHint, + EmailVerificationToken = localFactory.RegistrationTokens[email], + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserSymmetricKey = userSymmetricKey, + UserAsymmetricKeys = userAsymmetricKeys, + KdfMemory = kdfMemory, + KdfParallelism = kdfParallelism, + OpenOrgInvite = new OpenOrgInviteRequestModel + { + OrganizationId = inviteLink.OrganizationId, + Code = Guid.Parse(inviteLink.Code), + }, + }; + var finishCtx = await localFactory.PostRegisterFinishAsync(registerFinishReqModel); + + Assert.Equal(StatusCodes.Status200OK, finishCtx.Response.StatusCode); + + var database = localFactory.GetDatabaseContext(); + var user = await database.Users.SingleAsync(u => u.Email == email); + Assert.NotNull(user); + Assert.Equal(email, user.Email); + + // Assert both feature paths fired: the domain bypass produced a user row, AND Email 2FA + // was seeded because the same org has Require-2FA on. + var providers = user.GetTwoFactorProviders(); + Assert.NotNull(providers); + Assert.True(providers.TryGetValue(TwoFactorProviderType.Email, out var emailProvider)); + Assert.True(emailProvider!.Enabled); + Assert.Equal(email.ToLowerInvariant(), emailProvider.MetaData["Email"]?.ToString()); + } + [Theory, BitAutoData] public async Task RegistrationWithEmailVerification_WithOpenOrgInviteAndEmailDomainNotInAllowedDomains_ReturnsBadRequest( [Required] string name, [StringLength(1000), Required] string masterPasswordHash,