Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -91,6 +91,7 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IUpdateUserResetPasswordEnrollmentCommand _updateUserResetPasswordEnrollmentCommand;
private readonly IAcceptOrganizationInviteLinkCommand _acceptOrganizationInviteLinkCommand;
private readonly IConfirmOrganizationInviteLinkCommand _confirmOrganizationInviteLinkCommand;
private readonly IGetOrganizationInviteBlobCommand _getOrganizationInviteBlobCommand;

public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
Expand Down Expand Up @@ -126,7 +127,8 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
IUpdateUserResetPasswordEnrollmentCommand updateUserResetPasswordEnrollmentCommand,
IGetPendingAutoConfirmUsersQuery getPendingAutoConfirmUsersQuery,
IAcceptOrganizationInviteLinkCommand acceptOrganizationInviteLinkCommand,
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand)
IConfirmOrganizationInviteLinkCommand confirmOrganizationInviteLinkCommand,
IGetOrganizationInviteBlobCommand getOrganizationInviteBlobCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
Expand Down Expand Up @@ -163,6 +165,7 @@ public OrganizationUsersController(IOrganizationRepository organizationRepositor
_updateUserResetPasswordEnrollmentCommand = updateUserResetPasswordEnrollmentCommand;
_acceptOrganizationInviteLinkCommand = acceptOrganizationInviteLinkCommand;
_confirmOrganizationInviteLinkCommand = confirmOrganizationInviteLinkCommand;
_getOrganizationInviteBlobCommand = getOrganizationInviteBlobCommand;
}

[HttpGet("{id}")]
Expand Down Expand Up @@ -896,4 +899,24 @@ public async Task<IResult> ConfirmInviteLink([FromBody] ConfirmOrganizationInvit

return Handle(result, _ => TypedResults.Ok());
}

[HttpPost("/organizations/users/invite-link/invite-blob")]
[RequireFeature(FeatureFlagKeys.GenerateInviteLink)]
public async Task<IResult> GetInviteBlob([FromBody] GetOrganizationInviteBlobRequestModel model)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}

var result = await _getOrganizationInviteBlobCommand.GetInviteBlobAsync(new GetOrganizationInviteBlobRequest
{
OrganizationId = model.OrganizationId,
Code = model.Code,
User = user,
});

return Handle(result, inviteBlob => TypedResults.Ok(new OrganizationInviteBlobResponseModel(inviteBlob)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing System.ComponentModel.DataAnnotations;

namespace Bit.Api.AdminConsole.Models.Request.Organizations;

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

[Required]
public required Guid OrganizationId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ο»Ώnamespace Bit.Api.AdminConsole.Models.Response.Organizations;

public record OrganizationInviteBlobResponseModel(string InviteBlob);
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
ο»Ώusing Bit.Core.AdminConsole.AbilitiesCache;
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;

/// <summary>
/// Retrieves the opaque invite blob for an invite link. See
Comment thread
JimmyVo16 marked this conversation as resolved.
/// <see cref="IGetOrganizationInviteBlobCommand"/> for the behavior.
/// </summary>
public class GetOrganizationInviteBlobCommand(
IOrganizationInviteLinkRepository organizationInviteLinkRepository,
IOrganizationAbilityCacheService organizationAbilityCacheService)
: IGetOrganizationInviteBlobCommand
{
public async Task<CommandResult<string>> GetInviteBlobAsync(GetOrganizationInviteBlobRequest request)
{
var user = request.User;

var link = await organizationInviteLinkRepository.GetByOrganizationIdAsync(request.OrganizationId);
if (link is null || !link.CodeMatches(request.Code.ToString()))
{
return new InviteLinkNotFound();
}

var organizationAbility = await organizationAbilityCacheService.GetOrganizationAbilityAsync(link.OrganizationId);
if (organizationAbility is null or { Enabled: false })
{
return new InviteLinkNotFound();
}

if (!organizationAbility.UseInviteLinks)
{
return new InviteLinkNotAvailable();
}

if (!InviteLinkDomainValidator.IsEmailDomainAllowed(user.Email, link.GetAllowedDomains()))
{
return new EmailDomainNotAllowed();
}

return link.Invite;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
ο»Ώusing Bit.Core.Entities;

namespace Bit.Core.AdminConsole.OrganizationFeatures.InviteLinks;

/// <summary>
/// The data required to retrieve the invite blob for an invite link. The blob is an opaque
/// cryptographic value that the server stores and transports but never inspects; it is decrypted
/// client-side to reconstruct and confirm the invite link.
/// </summary>
public record GetOrganizationInviteBlobRequest
{
/// <summary>
/// The ID of the organization whose invite link the user is retrieving the blob for.
/// </summary>
public required Guid OrganizationId { get; init; }

/// <summary>
/// The secret code embedded in the invite link the user is retrieving the blob for.
/// </summary>
public required Guid Code { get; init; }

/// <summary>
/// The authenticated user requesting the invite blob.
/// </summary>
public required User User { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
ο»Ώusing Bit.Core.AdminConsole.Utilities.v2.Results;

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

/// <summary>
/// Retrieves the opaque invite blob for an invite link after validating that the link exists, its
/// organization is enabled and supports invite links, and the user's email domain is allowed.
/// </summary>
public interface IGetOrganizationInviteBlobCommand
{
Task<CommandResult<string>> GetInviteBlobAsync(GetOrganizationInviteBlobRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ private static void AddOrganizationInviteLinkCommandsQueries(this IServiceCollec
services.TryAddScoped<IAcceptOrganizationInviteLinkCommand, AcceptOrganizationInviteLinkCommand>();
services.TryAddScoped<IConfirmOrganizationInviteLinkValidator, ConfirmOrganizationInviteLinkValidator>();
services.TryAddScoped<IConfirmOrganizationInviteLinkCommand, ConfirmOrganizationInviteLinkCommand>();
services.TryAddScoped<IGetOrganizationInviteBlobCommand, GetOrganizationInviteBlobCommand>();
}

private static void AddOrganizationDomainCommandsQueries(this IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
ο»Ώusing System.Net;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Core;
using Bit.Core.AdminConsole.AbilitiesCache;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;

namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;

public class OrganizationUsersControllerGetInviteBlobTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime
{
private readonly HttpClient _client;
private readonly ApiApplicationFactory _factory;
private readonly LoginHelper _loginHelper;

private const string _validEncryptedKey =
"2.AOs41Hd8OQiCPXjyJKCiDA==|O6OHgt2U2hJGBSNGnimJmg==|iD33s8B69C8JhYYhSa4V1tArjvLr8eEaGqOV7BRo5Jk=";

private Organization _organization = null!;
private string _ownerEmail = null!;

public OrganizationUsersControllerGetInviteBlobTests(ApiApplicationFactory factory)
{
_factory = factory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.GenerateInviteLink)
.Returns(true);
featureService
.IsEnabled(FeatureFlagKeys.InviteLinkAutoConfirm)
.Returns(true);
});
_client = factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

public async Task InitializeAsync()
{
_ownerEmail = $"integration-test{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(_ownerEmail);

(_organization, _) = await OrganizationTestHelpers.SignUpAsync(
_factory,
plan: PlanType.EnterpriseAnnually,
ownerEmail: _ownerEmail,
passwordManagerSeats: 10,
paymentMethod: PaymentMethodType.Card);

var organizationRepository = _factory.GetService<IOrganizationRepository>();
_organization.UseInviteLinks = true;
await organizationRepository.ReplaceAsync(_organization);

// The endpoint reads Enabled/UseInviteLinks from the organization ability cache, so refresh it
// to reflect the invite links being enabled above.
await _factory.GetService<IOrganizationAbilityCacheService>()
.UpsertOrganizationAbilityAsync(_organization);

await _loginHelper.LoginAsync(_ownerEmail);
}

public Task DisposeAsync()
{
_client.Dispose();
return Task.CompletedTask;
}

[Fact]
public async Task GetInviteBlob_WithValidRequest_ReturnsOkAndInviteBlob()
{
// Arrange
var code = await CreateInviteLinkAsync(["example.com"]);
var (joinerClient, _) = await CreateJoinerClientAsync();

// Act
var response = await joinerClient.PostAsJsonAsync(
"/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, code));

// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var result = await response.Content.ReadFromJsonAsync<OrganizationInviteBlobResponseModel>();
Assert.NotNull(result);
Assert.Equal(_validEncryptedKey, result.InviteBlob);
}

[Fact]
public async Task GetInviteBlob_WithUnknownCode_ReturnsNotFound()
{
// Arrange
await CreateInviteLinkAsync(["example.com"]);
var (joinerClient, _) = await CreateJoinerClientAsync();

// Act β€” supply a code that does not match the organization's invite link
var response = await joinerClient.PostAsJsonAsync(
"/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, Guid.NewGuid()));

// Assert
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task GetInviteBlob_WhenEmailDomainNotAllowed_ReturnsBadRequest()
{
// Arrange β€” the link only allows a domain the joiner does not have
var code = await CreateInviteLinkAsync(["notallowed.example"]);
var (joinerClient, _) = await CreateJoinerClientAsync();

// Act
var response = await joinerClient.PostAsJsonAsync(
"/organizations/users/invite-link/invite-blob", BuildRequest(_organization.Id, code));

// Assert
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}

private async Task<Guid> CreateInviteLinkAsync(string[] allowedDomains)
{
var createRequest = new CreateOrganizationInviteLinkRequestModel
{
AllowedDomains = allowedDomains,
Invite = _validEncryptedKey,
SupportsConfirmation = true,
};
var createResponse = await _client.PostAsJsonAsync(
$"/organizations/{_organization.Id}/invite-link", createRequest);
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);

var created = await createResponse.Content.ReadFromJsonAsync<OrganizationInviteLinkResponseModel>();
Assert.NotNull(created);
return created.Code;
}

private async Task<(HttpClient Client, string Email)> CreateJoinerClientAsync()
{
var joinerEmail = $"integration-test{Guid.NewGuid()}@example.com";
await _factory.LoginWithNewAccount(joinerEmail);
var joinerClient = _factory.CreateClient();
var joinerLoginHelper = new LoginHelper(_factory, joinerClient);
await joinerLoginHelper.LoginAsync(joinerEmail);
return (joinerClient, joinerEmail);
}

private static GetOrganizationInviteBlobRequestModel BuildRequest(Guid organizationId, Guid code) =>
new()
{
OrganizationId = organizationId,
Code = code,
};
}
Loading
Loading