Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public class OrganizationInviteLinksController(
[HttpPost("/organizations/invite-link/status")]
public async Task<IResult> GetStatus([FromBody] GetOrganizationInviteLinkStatusRequestModel model)
{
var result = await getOrganizationInviteLinkStatusQuery.GetStatusAsync(model.Code);
var result = await getOrganizationInviteLinkStatusQuery.GetStatusAsync(model.OrganizationId, model.Code);

return Handle(result, status =>
TypedResults.Ok(new OrganizationInviteLinkStatusResponseModel(
Expand All @@ -45,7 +45,7 @@ status.Sso is null
[HttpPost("/organizations/invite-link/policies")]
public async Task<IResult> GetPolicies([FromBody] GetOrganizationInviteLinkPoliciesRequestModel model)
{
var result = await getOrganizationInviteLinkPoliciesQuery.GetPoliciesAsync(model.Code);
var result = await getOrganizationInviteLinkPoliciesQuery.GetPoliciesAsync(model.OrganizationId, model.Code);
return Handle(result, policies =>
TypedResults.Ok(new ListResponseModel<PolicyResponseModel>(
policies.Select(p => new PolicyResponseModel(p)))));
Expand All @@ -56,7 +56,7 @@ public async Task<IResult> GetPolicies([FromBody] GetOrganizationInviteLinkPolic
public async Task<IResult> ValidateEmailDomain(
[FromBody] OrganizationInviteLinkValidateEmailDomainRequestModel model)
{
var result = await validateOrganizationInviteLinkEmailDomainQuery.ValidateAsync(model.Code, model.Email);
var result = await validateOrganizationInviteLinkEmailDomainQuery.ValidateAsync(model.OrganizationId, model.Code, model.Email);

return Handle(result, isAllowed =>
TypedResults.Ok(new OrganizationInviteLinkValidateEmailDomainResponseModel(isAllowed)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ public async Task<IResult> AcceptInviteLink([FromBody] AcceptOrganizationInviteL

var result = await _acceptOrganizationInviteLinkCommand.AcceptAsync(new AcceptOrganizationInviteLinkRequest
{
OrganizationId = model.OrganizationId,
Code = model.Code,
User = user,
ResetPasswordKey = model.ResetPasswordKey,
Expand All @@ -885,6 +886,7 @@ public async Task<IResult> ConfirmInviteLink([FromBody] ConfirmOrganizationInvit

var result = await _confirmOrganizationInviteLinkCommand.ConfirmAsync(new ConfirmOrganizationInviteLinkRequest
{
OrganizationId = model.OrganizationId,
Code = model.Code,
User = user,
OrgUserKey = model.OrgUserKey,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class AcceptOrganizationInviteLinkRequestModel
{
[Required]
public required Guid OrganizationId { get; set; }

[Required]
public required Guid Code { get; set; }

public string? ResetPasswordKey { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class ConfirmOrganizationInviteLinkRequestModel
{
[Required]
public required Guid OrganizationId { get; set; }

[Required]
public required Guid Code { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class GetOrganizationInviteLinkPoliciesRequestModel
{
[Required]
public required Guid OrganizationId { get; set; }

[Required]
public required Guid Code { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class GetOrganizationInviteLinkStatusRequestModel
{
[Required]
public required Guid OrganizationId { get; set; }

[Required]
public required Guid Code { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations;

public class OrganizationInviteLinkValidateEmailDomainRequestModel
{
[Required]
public required Guid OrganizationId { get; set; }

[Required]
public required Guid Code { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public OrganizationInviteLinkResponseModel(OrganizationInviteLink inviteLink)
ArgumentNullException.ThrowIfNull(inviteLink);

Id = inviteLink.Id;
Code = inviteLink.Code;
Code = Guid.Parse(inviteLink.Code);
OrganizationId = inviteLink.OrganizationId;
AllowedDomains = inviteLink.GetAllowedDomains();
Invite = inviteLink.Invite;
Expand Down
37 changes: 31 additions & 6 deletions src/Core/AdminConsole/Entities/OrganizationInviteLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,7 @@ public class OrganizationInviteLink : ITableObject<Guid>
/// <summary>
/// A random, secret code embedded in the invite link to ensure it cannot be guessed.
/// </summary>
/// <remarks>
/// Uses <see cref="Guid.NewGuid"/> rather than a sequential/comb GUID because this is not
/// a table identifier and therefore does not need index-friendly ordering. A comb GUID's embedded
/// timestamp would also make the code partially predictable.
/// </remarks>
public Guid Code { get; set; } = Guid.NewGuid();
public string Code { get; set; } = null!;
/// <summary>
/// The ID of the <see cref="Organization"/> this invite link belongs to.
/// </summary>
Expand Down Expand Up @@ -75,4 +70,34 @@ public void SetNewId()
Id = CoreHelpers.GenerateComb();
}
}

/// <summary>
/// Initializes <see cref="Code"/> to a new random GUID string.
/// </summary>
/// <remarks>
/// Uses <see cref="Guid.NewGuid"/> rather than a sequential/comb GUID because this is a
/// bearer secret β€” not a table identifier β€” and does not need index-friendly ordering.
/// A comb GUID's embedded timestamp would also make the code partially predictable.
/// </remarks>
public void SetNewCode()
{
if (string.IsNullOrEmpty(Code))
{
Code = Guid.NewGuid().ToString();
}
}

/// <summary>
/// Constant-time comparison of <paramref name="providedCode"/> against <see cref="Code"/>;
/// returns false for null or empty input.
/// </summary>
public bool CodeMatches(string? providedCode)
{
if (string.IsNullOrEmpty(providedCode) || string.IsNullOrEmpty(Code))
{
return false;
}

return CoreHelpers.FixedTimeEquals(providedCode, Code);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ public async Task<CommandResult<OrganizationUser>> AcceptAsync(AcceptOrganizatio
{
var user = request.User;

var link = await organizationInviteLinkRepository.GetByCodeAsync(request.Code);
if (link is null)
var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (link is null || !link.CodeMatches(request.Code.ToString()))
{
return new InviteLinkNotFound();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

public record AcceptOrganizationInviteLinkRequest
{
public required Guid OrganizationId { get; init; }
public required Guid Code { get; init; }
public required User User { get; init; }
public string? ResetPasswordKey { get; init; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public async Task<CommandResult> ConfirmAsync(ConfirmOrganizationInviteLinkReque
var validationResult = await confirmOrganizationInviteLinkValidator.ValidateAsync(
new ConfirmOrganizationInviteLinkValidationRequest
{
OrganizationId = request.OrganizationId,
Code = request.Code,
User = user,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
/// </summary>
public record ConfirmOrganizationInviteLinkRequest
{
/// <summary>
/// The organization the user is confirming into.
/// </summary>
public required Guid OrganizationId { get; init; }

/// <summary>
/// The secret code embedded in the invite link the user is confirming with.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;
/// </summary>
public record ConfirmOrganizationInviteLinkValidationRequest
{
/// <summary>
/// The organization the user is attempting to join.
/// </summary>
public required Guid OrganizationId { get; init; }

/// <summary>
/// The secret code embedded in the invite link the user is attempting to use.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ public async Task<CommandResult<ConfirmOrganizationInviteLinkValidationResult>>
{
var user = request.User;

var link = await organizationInviteLinkRepository.GetByCodeAsync(request.Code);
if (link is null)
var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (link is null || !link.CodeMatches(request.Code.ToString()))
{
return new InviteLinkNotFound();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public async Task<CommandResult<OrganizationInviteLink>> CreateAsync(
};
inviteLink.SetAllowedDomains(sanitizedDomains);
inviteLink.SetNewId();
inviteLink.SetNewCode();

await organizationInviteLinkRepository.CreateAsync(inviteLink);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ public class GetOrganizationInviteLinkPoliciesQuery(
IPolicyRepository policyRepository)
: IGetOrganizationInviteLinkPoliciesQuery
{
public async Task<CommandResult<ICollection<Policy>>> GetPoliciesAsync(Guid code)
public async Task<CommandResult<ICollection<Policy>>> GetPoliciesAsync(Guid organizationId, Guid code)
{
var inviteLink = await organizationInviteLinkRepository.GetByCodeAsync(code);
if (inviteLink is null)
var inviteLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId);
if (inviteLink is null || !inviteLink.CodeMatches(code.ToString()))
{
return new InviteLinkNotFound();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ public class GetOrganizationInviteLinkStatusQuery(
IPolicyRepository policyRepository)
: IGetOrganizationInviteLinkStatusQuery
{
public async Task<CommandResult<OrganizationInviteLinkStatus>> GetStatusAsync(Guid code)
public async Task<CommandResult<OrganizationInviteLinkStatus>> GetStatusAsync(Guid organizationId, Guid code)
{
var inviteLink = await organizationInviteLinkRepository.GetByCodeAsync(code);
if (inviteLink is null)
var inviteLink = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId);
if (inviteLink is null || !inviteLink.CodeMatches(code.ToString()))
{
return new InviteLinkNotFound();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
public interface IGetOrganizationInviteLinkPoliciesQuery
{
/// <summary>
/// Gets the enabled policies for an organization by invite link code, for display to an anonymous user.
/// Gets the enabled policies for an organization by invite link, for display to an anonymous user.
/// </summary>
/// <param name="code">The public invite link code (from the URL).</param>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code (bearer secret from the URL).</param>
/// <returns>
/// The enabled <see cref="Policy"/> records for the organization if the link is valid, or an error
/// if the link is not found, the organization is disabled, or the feature / policies are unavailable.
/// </returns>
Task<CommandResult<ICollection<Policy>>> GetPoliciesAsync(Guid code);
Task<CommandResult<ICollection<Policy>>> GetPoliciesAsync(Guid organizationId, Guid code);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks.Interfaces;
public interface IGetOrganizationInviteLinkStatusQuery
{
/// <summary>
/// Gets the status of an invite link by its public code, for display to an anonymous user.
/// Gets the status of an invite link by its organization ID and code, for display to an anonymous user.
/// </summary>
/// <param name="code">The public invite link code (from the URL).</param>
/// <param name="organizationId">The organization's ID (from the URL path).</param>
/// <param name="code">The public invite link code (bearer secret from the URL).</param>
/// <returns>
/// An <see cref="OrganizationInviteLinkStatus"/> if the link is valid, or an error if the link is not
/// found or the organization is disabled. The status indicates whether the invite links feature is enabled.
/// </returns>
Task<CommandResult<OrganizationInviteLinkStatus>> GetStatusAsync(Guid code);
Task<CommandResult<OrganizationInviteLinkStatus>> GetStatusAsync(Guid organizationId, Guid code);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public interface IValidateOrganizationInviteLinkEmailDomainQuery
{
/// <summary>
/// Returns whether the email's domain is allowed by the invite link,
/// or an error if the invite link does not exist.
/// or an error if the invite link does not exist or the code does not match.
/// </summary>
Task<CommandResult<bool>> ValidateAsync(Guid code, string email);
Task<CommandResult<bool>> ValidateAsync(Guid organizationId, Guid code, string email);
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public async Task<CommandResult<OrganizationInviteLink>> RefreshAsync(
RevisionDate = now,
};
newLink.SetNewId();
newLink.SetNewCode();

await organizationInviteLinkRepository.RefreshAsync(existing, newLink);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ public class ValidateOrganizationInviteLinkEmailDomainQuery(
IOrganizationInviteLinkRepository organizationInviteLinkRepository)
: IValidateOrganizationInviteLinkEmailDomainQuery
{
public async Task<CommandResult<bool>> ValidateAsync(Guid code, string email)
public async Task<CommandResult<bool>> ValidateAsync(Guid organizationId, Guid code, string email)
{
var link = await organizationInviteLinkRepository.GetByCodeAsync(code);
if (link is null)
var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(organizationId);
if (link is null || !link.CodeMatches(code.ToString()))
{
return new InviteLinkNotFound();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,6 @@ namespace Bit.Core.AdminConsole.Repositories;

public interface IOrganizationInviteLinkRepository : IRepository<OrganizationInviteLink, Guid>
{
/// <summary>
/// Gets an organization invite link by its code.
/// </summary>
/// <param name="code">The code of the organization invite link.</param>
/// <returns>The organization invite link if found, otherwise null.</returns>
Task<OrganizationInviteLink?> GetByCodeAsync(Guid code);

/// <summary>
/// Gets an organization invite link by its organization ID.
/// </summary>
Expand Down
Loading
Loading