Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,7 +24,7 @@ public class OrganizationInviteLinksController(
IUpdateInviteSupportConfirmCommand updateInviteSupportConfirmCommand,
IDeleteOrganizationInviteLinkCommand deleteOrganizationInviteLinkCommand,
IRefreshOrganizationInviteLinkCommand refreshOrganizationInviteLinkCommand,
IValidateOrganizationInviteLinkEmailDomainQuery validateOrganizationInviteLinkEmailDomainQuery,
IValidateOrganizationInviteLinkQuery validateOrganizationInviteLinkQuery,
IGetOrganizationInviteLinkPoliciesQuery getOrganizationInviteLinkPoliciesQuery)
: BaseAdminConsoleController
{
Expand Down Expand Up @@ -62,10 +63,17 @@ public async Task<IResult> GetPolicies([FromBody] GetOrganizationInviteLinkPolic
public async Task<IResult> 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("")]
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;

public interface IValidateOrganizationInviteLinkQuery
{
/// <summary>
/// Validates that an open organization invite link is valid and that the email
/// matches its allowed domains. It does NOT check that the email has been
/// verified - the caller must check this separately if required.
/// </summary>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code.</param>
/// <param name="email">The registering user's email; checked against the link's AllowedDomains.</param>
/// <returns>
/// A successful CommandResult if the link is valid and matches the email; a failed
/// CommandResult if validation fails or the invite link is otherwise not available.
/// </returns>
Comment on lines +7 to +18

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the feedback. Resolved with 4a56ca2

Task<CommandResult> ValidateAsync(Guid organizationId, Guid code, string email);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Details and suggestion

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

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

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

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

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

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

@JaredSnider-Bitwarden JaredSnider-Bitwarden Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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

To address this, please:

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

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

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for the feedback. Resolved with 4a56ca2

}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<CommandResult> 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);
Comment thread
JaredSnider-Bitwarden marked this conversation as resolved.
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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ο»Ώnamespace Bit.Core.Auth.Models.Api.Request.Accounts;

/// <summary>
/// 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
/// <see cref="RegisterStartOpenOrgInviteRequestModel"/> with a sealed data blob at register-start.
/// </summary>
public class OpenOrgInviteRequestModel
{
public required Guid OrganizationId { get; set; }

public required Guid Code { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -245,7 +247,8 @@ public IEnumerable<ValidationResult> 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.
Comment on lines +250 to +251

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Less is more.

RegisterFinishTokenType tokenType;
var tokenTypeResolved = true;
try
Expand All @@ -264,6 +267,15 @@ public IEnumerable<ValidationResult> 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -18,10 +15,5 @@ public class RegisterSendVerificationEmailRequestModel
[MarketingInitiativeValidation]
public string? FromMarketing { get; set; }

/// <summary>
/// Opaque SDK-produced blob for open-org-invite registrations. Echoed to the verification
/// email URL; never parsed server-side.
/// </summary>
[MaxLength(SealedOpenOrgInviteDataMaxLength)]
public string? SealedOpenOrgInviteData { get; set; }
public RegisterStartOpenOrgInviteRequestModel? OpenOrgInvite { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
ο»Ώusing System.ComponentModel.DataAnnotations;

namespace Bit.Core.Auth.Models.Api.Request.Accounts;

/// <summary>
/// Register-start payload for an open organization invite link: the {organizationId, code}
/// reference (inherited from <see cref="OpenOrgInviteRequestModel"/>) 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.
/// </summary>
public class RegisterStartOpenOrgInviteRequestModel : OpenOrgInviteRequestModel
{
private const int SealedOpenOrgInviteDataMaxLength = 4096;

[Required]
[MaxLength(SealedOpenOrgInviteDataMaxLength)]
public required string SealedOpenOrgInviteData { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,7 +48,25 @@ public interface IRegisterUserCommand
/// <param name="registerFinishData">Cryptographic data for finishing user registration</param>
/// <param name="emailVerificationToken">The email verification token sent to the user via email</param>
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData, string emailVerificationToken);
public Task<IdentityResult> RegisterUserViaEmailVerificationToken(User user, RegisterFinishData registerFinishData,
string emailVerificationToken);

/// <summary>
/// Creates a new user via an email-verification token in the presence of a validated
/// <see cref="OpenOrgInviteRequestModel"/>. 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
/// <see cref="RegisterUserViaEmailVerificationToken"/> because the open-org-invite flow will
/// enforce additional org-membership-related obligations that don't apply to vanilla
/// email-verification registration.
/// </summary>
/// <param name="user">The <see cref="User"/> to create</param>
/// <param name="registerFinishData">Cryptographic data for finishing user registration</param>
/// <param name="emailVerificationToken">The email verification token sent to the user via email</param>
/// <param name="openOrgInvite">The open-org-invite payload from the client β€” {orgId, code}.</param>
/// <returns><see cref="IdentityResult"/></returns>
public Task<IdentityResult> RegisterUserViaEmailVerificationTokenAndOpenOrgInvite(
User user, RegisterFinishData registerFinishData, string emailVerificationToken, OpenOrgInviteRequestModel openOrgInvite);

/// <summary>
/// Creates a new user with a given master password hash, sends a welcome email, and raises the signup reference event.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
ο»Ώ#nullable enable
using Bit.Core.Auth.Models.Api.Request.Accounts;

namespace Bit.Core.Auth.UserFeatures.Registration;

public interface ISendVerificationEmailForRegistrationCommand
Expand All @@ -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.
/// </summary>
/// <param name="sealedOpenOrgInviteData">
/// Optional opaque SDK-produced blob. Echoed to the verification email URL on the new-user
/// branch; dropped on the existing-user branch (anti-enumeration).
/// <param name="openOrgInvite">
/// 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.
/// </param>
public Task<string?> Run(string email, string? name, bool receiveMarketingEmails, string? fromMarketing,
string? sealedOpenOrgInviteData = null);
RegisterStartOpenOrgInviteRequestModel? openOrgInvite = null);
}
Loading
Loading