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 new file mode 100644 index 000000000000..201e3b96fdf8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/Interfaces/IValidateOrganizationInviteLinkQuery.cs @@ -0,0 +1,20 @@ +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 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. + /// + /// 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 new file mode 100644 index 000000000000..25638132dfe8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQuery.cs @@ -0,0 +1,41 @@ +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; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks; + +public class ValidateOrganizationInviteLinkQuery( + IOrganizationInviteLinkRepository organizationInviteLinkRepository, + IOrganizationRepository organizationRepository) + : IValidateOrganizationInviteLinkQuery +{ + public async Task ValidateAsync(Guid organizationId, Guid code, string email) + { + 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(); + } + + if (!InviteLinkDomainValidator.IsEmailDomainAllowed(email, inviteLink.GetAllowedDomains())) + { + return new EmailDomainNotAllowed(organization.DisplayName()); + } + + return new None(); + } +} 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..333d5da2c560 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/OpenOrgInviteRequestModel.cs @@ -0,0 +1,13 @@ +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 +{ + public required Guid OrganizationId { get; set; } + + 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..93132a529534 --- /dev/null +++ b/src/Core/Auth/Models/Api/Request/Accounts/RegisterStartOpenOrgInviteRequestModel.cs @@ -0,0 +1,18 @@ +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..510e7594348c 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; @@ -230,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; } @@ -284,6 +275,63 @@ 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, user.Email); + if (validationResult.IsError) + { + throw new BadRequestException("Invalid or expired organization invite link."); + } + + await ValidateEmailDomainNotBlockedAsync(user.Email, openOrgInvite.OrganizationId); + + 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. + + var result = await _userService.CreateUserAsync(user, registerFinishData); + if (result == IdentityResult.Success) + { + 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/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/SendVerificationEmailForRegistrationCommand.cs index 9308d8088be1..fdb8b410269c 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, email); + 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/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index fa0b4651acce..88f631100866 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -208,8 +208,8 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); - services.TryAddScoped(); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs index 5075849602bc..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,8 +107,10 @@ 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.SealedOpenOrgInviteData); + model.ReceiveMarketingEmails, model.FromMarketing, model.OpenOrgInvite); if (token != null) { @@ -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()); @@ -143,10 +164,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/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 new file mode 100644 index 000000000000..ecd7090c1743 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/InviteLinks/ValidateOrganizationInviteLinkQueryTests.cs @@ -0,0 +1,203 @@ +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_WithValidLinkAndAllowedDomainEmail_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(); + 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, email); + + 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, + string email, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByOrganizationIdAsync(organizationId).ReturnsNull(); + + var result = await sutProvider.Sut.ValidateAsync(organizationId, code, email); + + 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, + string email, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetByOrganizationIdAsync(inviteLink.OrganizationId).Returns(inviteLink); + + var result = await sutProvider.Sut.ValidateAsync(inviteLink.OrganizationId, Guid.NewGuid(), email); + + 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, + string email, + 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, email); + + Assert.True(result.IsError); + Assert.IsType(result.AsError); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_OrganizationDisabled_ReturnsInviteLinkNotFound( + OrganizationInviteLink inviteLink, + Organization organization, + string email, + SutProvider sutProvider) + { + 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() + .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_UseInviteLinksFalse_ReturnsInviteLinkNotAvailable( + OrganizationInviteLink inviteLink, + Organization organization, + string email, + 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, 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/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)); } 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..f4d5e39e1982 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,633 @@ 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, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) + { + // Arrange + user.Email = $"test+{Guid.NewGuid()}@example.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code, Arg.Any()) + .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); + + 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, Arg.Any()) + .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); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); + } + + [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, Arg.Any()) + .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); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); + } + + [Theory] + [BitAutoData] + public async Task RegisterUserViaEmailVerificationTokenAndOpenOrgInvite_LinksEnabled_UnblocksClaimedDomain( + SutProvider sutProvider, User user, RegisterFinishData registerFinishData, + string emailVerificationToken, bool receiveMarketingMaterials, + Guid organizationId, Guid code, + [Policy(PolicyType.TwoFactorAuthentication, false)] PolicyStatus policy) + { + // Arrange + user.Email = "user@claimed-domain.com"; + var openOrgInvite = new OpenOrgInviteRequestModel { OrganizationId = organizationId, Code = code }; + + sutProvider.GetDependency() + .ValidateAsync(organizationId, code, Arg.Any()) + .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() + .RunAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policy); + + 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, default!); + sutProvider.GetDependency>() + .DidNotReceiveWithAnyArgs() + .TryUnprotect(default, out Arg.Any()); + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .RunAsync(default, default); + } + + [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, Arg.Any()) + .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); + + 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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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); + } + + [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/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/SendVerificationEmailForRegistrationCommandTests.cs index 5230faffb01c..b64e7c8351f2 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, Arg.Any()) + .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, Arg.Any()) + .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(), 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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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, Arg.Any()) + .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..6d59d0f1254c 100644 --- a/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs +++ b/test/Identity.IntegrationTest/Controllers/AccountsControllerTests.cs @@ -1,7 +1,12 @@ 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; 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; @@ -25,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) @@ -96,38 +104,34 @@ 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. + // OpenOrgInvite payload without a matching invite link on the org is rejected. var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); - var email = $"test+register+sealed+{name}@email.com"; - var sealedOpenOrgInviteData = "opaque-base64url-blob-representing-a-realistic-sdk-output"; + 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); - - // 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 +142,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 +156,309 @@ public async Task PostRegisterSendEmailVerification_WithOversizedSealedOpenOrgIn } [Theory, BitAutoData] - public async Task PostRegisterSendEmailVerification_WithSealedOpenOrgInviteData_ForExistingUser_SilentlyDiscardsSealedData(string name, bool receiveMarketingEmails) + public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ReturnsNotFound(string name, bool receiveMarketingEmails) { - // Existing user + sealed data → 204 with no mail sent (anti-enumeration). + // 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 email = $"test+register+existing+{name}@email.com"; - await CreateUserAsync(email, name, localFactory); + 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}"; + 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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+attacker+{name}@{claimedDomain}"; + await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); + // 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 + { + 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); + + 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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + 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, 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 = $"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(allowedDomains ?? new[] { claimedDomain }); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); - await localFactory.GetService() - .DidNotReceive() - .SendRegistrationVerificationEmailAsync( - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + return (organization, inviteLink); + } + + private static async Task<(Organization Org, OrganizationInviteLink InviteLink)> SeedOrgWithInviteLinkAndTwoFactorPolicyAsync( + IdentityApplicationFactory factory, IEnumerable? allowedDomains = null) + { + 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(allowedDomains ?? new[] { "email.com" }); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + 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) + { + 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, + }; + inviteLink.SetAllowedDomains(allowedDomains ?? Array.Empty()); + inviteLink.SetNewId(); + inviteLink.SetNewCode(); + await organizationInviteLinkRepository.CreateAsync(inviteLink); + + return (organization, inviteLink); } @@ -314,6 +599,383 @@ 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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + 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); + + // 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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + 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] + 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, + [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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + 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, + [Required] KeysRequestModel userAsymmetricKeys) + { + userAsymmetricKeys.AccountKeys = null; + var localFactory = new IdentityApplicationFactory(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + 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(); + localFactory.UpdateConfiguration(GenerateInviteLinkFlagSettingKey, "true"); + + var claimedDomain = $"claimed-{Guid.NewGuid():N}.example.com"; + var email = $"test+attackerfinish+{name}@{claimedDomain}"; + var (_, orgAInvite) = await SeedOrgWithClaimedDomainAndInviteLinkAsync(localFactory, claimedDomain); + // 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 + { + 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..adf0cd4e4474 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; @@ -10,6 +11,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; @@ -18,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; @@ -37,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; @@ -48,6 +52,7 @@ public AccountsControllerTests() _getWebAuthnLoginCredentialAssertionOptionsCommand = Substitute.For(); _sendVerificationEmailForRegistrationCommand = Substitute.For(); _registrationEmailVerificationTokenDataFactory = Substitute.For>(); + _featureService = Substitute.For(); _globalSettings = Substitute.For(); _sut = new AccountsController( @@ -57,6 +62,7 @@ public AccountsControllerTests() _getWebAuthnLoginCredentialAssertionOptionsCommand, _sendVerificationEmailForRegistrationCommand, _registrationEmailVerificationTokenDataFactory, + _featureService, _globalSettings ); } @@ -323,17 +329,23 @@ 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"; + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(true); + 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 +353,59 @@ public async Task PostRegisterSendEmailVerification_PassesSealedOpenOrgInviteDat // Assert await _sendVerificationEmailForRegistrationCommand.Received(1) - .Run(email, name, receiveMarketingEmails, null, sealedOpenOrgInviteData); + .Run(email, name, receiveMarketingEmails, null, openOrgInvite); + } + + [Theory] + [BitAutoData] + public async Task PostRegisterSendEmailVerification_WithOpenOrgInviteAndFeatureFlagOff_ThrowsFeatureUnavailable( + 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] @@ -603,6 +667,105 @@ 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 + _featureService.IsEnabled(FeatureFlagKeys.GenerateInviteLink).Returns(true); + 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_WithOpenOrgInviteAndFeatureFlagOff_ThrowsFeatureUnavailable( + 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,