1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

[PM-1815] Include Member Decryption Type in Token Response (#2927)

* Include Member Decryption Type

* Make ICurrentContext protected from base class

* Return MemberDecryptionType

* Extend WebApplicationFactoryBase

- Allow for service subsitution

* Create SSO Tests

- Mock IAuthorizationCodeStore so the SSO process can be limited to Identity

* Add MemberDecryptionOptions

* Remove Unused Property Assertion

* Make MemberDecryptionOptions an Array

* Address PR Feedback

* Make HasAdminApproval Policy Aware

* Format

* Use Object Instead

* Add UserDecryptionOptions File
This commit is contained in:
Justin Baur 2023-06-19 10:16:15 -04:00 committed by GitHub
parent ca7ced4e43
commit 5a8e549194
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 551 additions and 28 deletions

View File

@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Core.Auth.Models.Api.Response;
public class UserDecryptionOptions : ResponseModel
{
public UserDecryptionOptions() : base("userDecryptionOptions")
{
}
/// <summary>
///
/// </summary>
public bool HasMasterPassword { get; set; }
/// <summary>
///
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public TrustedDeviceUserDecryptionOption? TrustedDeviceOption { get; set; }
/// <summary>
///
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; }
}
public class TrustedDeviceUserDecryptionOption
{
public bool HasAdminApproval { get; }
public TrustedDeviceUserDecryptionOption(bool hasAdminApproval)
{
HasAdminApproval = hasAdminApproval;
}
}
public class KeyConnectorUserDecryptionOption
{
public string KeyConnectorUrl { get; }
public KeyConnectorUserDecryptionOption(string keyConnectorUrl)
{
KeyConnectorUrl = keyConnectorUrl;
}
}

View File

@ -37,12 +37,13 @@ public abstract class BaseRequestValidator<T> where T : class
private readonly IApplicationCacheService _applicationCacheService; private readonly IApplicationCacheService _applicationCacheService;
private readonly IMailService _mailService; private readonly IMailService _mailService;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly ICurrentContext _currentContext;
private readonly GlobalSettings _globalSettings; private readonly GlobalSettings _globalSettings;
private readonly IPolicyService _policyService;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory; private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _tokenDataFactory;
protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
public BaseRequestValidator( public BaseRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
IDeviceRepository deviceRepository, IDeviceRepository deviceRepository,
@ -73,11 +74,10 @@ public abstract class BaseRequestValidator<T> where T : class
_applicationCacheService = applicationCacheService; _applicationCacheService = applicationCacheService;
_mailService = mailService; _mailService = mailService;
_logger = logger; _logger = logger;
_currentContext = currentContext; CurrentContext = currentContext;
_globalSettings = globalSettings; _globalSettings = globalSettings;
_policyService = policyService; PolicyService = policyService;
_userRepository = userRepository; _userRepository = userRepository;
_policyService = policyService;
_tokenDataFactory = tokenDataFactory; _tokenDataFactory = tokenDataFactory;
} }
@ -284,7 +284,7 @@ public abstract class BaseRequestValidator<T> where T : class
{ {
_logger.LogWarning(Constants.BypassFiltersEventId, _logger.LogWarning(Constants.BypassFiltersEventId,
string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".", string.Format("Failed login attempt{0}{1}", twoFactorRequest ? ", 2FA invalid." : ".",
$" {_currentContext.IpAddress}")); $" {CurrentContext.IpAddress}"));
} }
await Task.Delay(2000); // Delay for brute force. await Task.Delay(2000); // Delay for brute force.
@ -314,7 +314,7 @@ public abstract class BaseRequestValidator<T> where T : class
(await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0; (await _userManager.GetValidTwoFactorProvidersAsync(user)).Count > 0;
Organization firstEnabledOrg = null; Organization firstEnabledOrg = null;
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
.ToList(); .ToList();
if (orgs.Any()) if (orgs.Any())
{ {
@ -341,7 +341,7 @@ public abstract class BaseRequestValidator<T> where T : class
} }
// Check if user belongs to any organization with an active SSO policy // Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await _policyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser) if (anySsoPoliciesApplicableToUser)
{ {
return false; return false;
@ -501,7 +501,7 @@ public abstract class BaseRequestValidator<T> where T : class
if (!_globalSettings.DisableEmailNewDevice) if (!_globalSettings.DisableEmailNewDevice)
{ {
await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now, await _mailService.SendNewDeviceLoggedInEmail(user.Email, deviceType, now,
_currentContext.IpAddress); CurrentContext.IpAddress);
} }
} }
@ -543,11 +543,11 @@ public abstract class BaseRequestValidator<T> where T : class
{ {
if (twoFactorInvalid) if (twoFactorInvalid)
{ {
await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, _currentContext.IpAddress); await _mailService.SendFailedTwoFactorAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
} }
else else
{ {
await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, _currentContext.IpAddress); await _mailService.SendFailedLoginAttemptsEmailAsync(user.Email, utcNow, CurrentContext.IpAddress);
} }
} }
} }
@ -562,7 +562,7 @@ public abstract class BaseRequestValidator<T> where T : class
private async Task<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicy(User user) private async Task<MasterPasswordPolicyResponseModel> GetMasterPasswordPolicy(User user)
{ {
// Check current context/cache to see if user is in any organizations, avoids extra DB call if not // Check current context/cache to see if user is in any organizations, avoids extra DB call if not
var orgs = (await _currentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id)) var orgs = (await CurrentContext.OrganizationMembershipAsync(_organizationUserRepository, user.Id))
.ToList(); .ToList();
if (!orgs.Any()) if (!orgs.Any())
@ -570,6 +570,6 @@ public abstract class BaseRequestValidator<T> where T : class
return null; return null;
} }
return new MasterPasswordPolicyResponseModel(await _policyService.GetMasterPasswordPolicyForUserAsync(user)); return new MasterPasswordPolicyResponseModel(await PolicyService.GetMasterPasswordPolicyForUserAsync(user));
} }
} }

View File

@ -1,10 +1,14 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Core;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@ -15,13 +19,16 @@ using IdentityServer4.Extensions;
using IdentityServer4.Validation; using IdentityServer4.Validation;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
#nullable enable
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;
public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>, public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenRequestValidationContext>,
ICustomTokenRequestValidator ICustomTokenRequestValidator
{ {
private UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IFeatureService _featureService;
public CustomTokenRequestValidator( public CustomTokenRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
@ -41,7 +48,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserRepository userRepository, IUserRepository userRepository,
IPolicyService policyService, IPolicyService policyService,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory) IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> tokenDataFactory,
IFeatureService featureService)
: base(userManager, deviceRepository, deviceService, userService, eventService, : base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository, organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository, applicationCacheService, mailService, logger, currentContext, globalSettings, policyRepository,
@ -49,6 +57,7 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
{ {
_userManager = userManager; _userManager = userManager;
_ssoConfigRepository = ssoConfigRepository; _ssoConfigRepository = ssoConfigRepository;
_featureService = featureService;
} }
public async Task ValidateAsync(CustomTokenRequestValidationContext context) public async Task ValidateAsync(CustomTokenRequestValidationContext context)
@ -101,6 +110,20 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
} }
} }
// Attempts to find ssoConfigData for a given validate request subject
// this is actually guarenteed to pretty often be null, because more than just sso login requests will come
// through here
var ssoConfigData = await GetSsoConfigurationDataAsync(context.Result.ValidatedRequest.Subject);
// You can't put this below the user.MasterPassword != null check because TDE users can still have a MasterPassword
// It's worth noting that CurrentContext here will build a user in LaunchDarkly that is anonymous but DOES belong
// to an organization. So we will not be able to turn this feature on for only a single user, only for an entire
// organization at a time.
if (ssoConfigData != null && _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext))
{
context.Result.CustomResponse["UserDecryptionOptions"] = await CreateUserDecryptionOptionsAsync(ssoConfigData, user);
}
if (context.Result.CustomResponse == null || user.MasterPassword != null) if (context.Result.CustomResponse == null || user.MasterPassword != null)
{ {
return; return;
@ -122,23 +145,34 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
} }
// SSO login // SSO login
var organizationClaim = context.Result.ValidatedRequest.Subject?.FindFirst(c => c.Type == "organizationId"); // This does a double check, that ssoConfigData is not null and that it has the KeyConnector member decryption type
if (organizationClaim?.Value != null) if (ssoConfigData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl))
{ {
var organizationId = new Guid(organizationClaim.Value); // TODO: Can be removed in the future
context.Result.CustomResponse["KeyConnectorUrl"] = ssoConfigData.KeyConnectorUrl;
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId); // Prevent clients redirecting to set-password
var ssoConfigData = ssoConfig.GetData(); context.Result.CustomResponse["ResetMasterPassword"] = false;
if (ssoConfigData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl))
{
context.Result.CustomResponse["KeyConnectorUrl"] = ssoConfigData.KeyConnectorUrl;
// Prevent clients redirecting to set-password
context.Result.CustomResponse["ResetMasterPassword"] = false;
}
} }
} }
private async Task<SsoConfigurationData?> GetSsoConfigurationDataAsync(ClaimsPrincipal? subject)
{
var organizationClaim = subject?.FindFirstValue("organizationId");
if (organizationClaim == null || !Guid.TryParse(organizationClaim, out var organizationId))
{
return null;
}
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(organizationId);
if (ssoConfig == null)
{
return null;
}
return ssoConfig.GetData();
}
protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context, protected override void SetTwoFactorResult(CustomTokenRequestValidationContext context,
Dictionary<string, object> customResponse) Dictionary<string, object> customResponse)
{ {
@ -164,4 +198,29 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
context.Result.IsError = true; context.Result.IsError = true;
context.Result.CustomResponse = customResponse; context.Result.CustomResponse = customResponse;
} }
/// <summary>
/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
/// </summary>
private async Task<UserDecryptionOptions> CreateUserDecryptionOptionsAsync(SsoConfigurationData ssoConfigurationData, User user)
{
var userDecryptionOption = new UserDecryptionOptions();
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
{
// KeyConnector makes it mutually exclusive
userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
return userDecryptionOption;
}
if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
{
var hasAdminApproval = await PolicyService.AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.ResetPassword);
// TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true
userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(hasAdminApproval);
}
userDecryptionOption.HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword);
return userDecryptionOption;
}
} }

View File

@ -0,0 +1,391 @@
using System.Security.Claims;
using System.Text.Json;
using Bit.Core;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.Helpers;
using IdentityModel;
using IdentityServer4.Models;
using IdentityServer4.Stores;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
#nullable enable
namespace Bit.Identity.IntegrationTest.Endpoints;
public class IdentityServerSsoTests
{
const string TestEmail = "sso_user@email.com";
[Fact]
public async Task Test_MasterPassword_DecryptionType()
{
// Arrange
var challenge = new string('c', 50);
var factory = await CreateFactoryAsync(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.MasterPassword,
}, challenge);
// Act
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", "10" },
{ "deviceIdentifier", "test_id" },
{ "deviceName", "firefox" },
{ "twoFactorToken", "TEST"},
{ "twoFactorProvider", "5" }, // RememberMe Provider
{ "twoFactorRemember", "0" },
{ "grant_type", "authorization_code" },
{ "code", "test_code" },
{ "code_verifier", challenge },
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
}));
// Assert
// If the organization has a member decryption type of MasterPassword that should be the only option in the reply
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = responseBody.RootElement;
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
// Expected to look like:
// "UserDecryptionOptions": {
// "Object": "userDecryptionOptions"
// "HasMasterPassword": true
// }
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
// One property for the Object and one for master password
Assert.Equal(2, userDecryptionOptions.EnumerateObject().Count());
}
[Fact]
public async Task SsoLogin_TrustedDeviceEncryption_ReturnsOptions()
{
// Arrange
var challenge = new string('c', 50);
var factory = await CreateFactoryAsync(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
}, challenge);
// Act
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", "10" },
{ "deviceIdentifier", "test_id" },
{ "deviceName", "firefox" },
{ "twoFactorToken", "TEST"},
{ "twoFactorProvider", "5" }, // RememberMe Provider
{ "twoFactorRemember", "0" },
{ "grant_type", "authorization_code" },
{ "code", "test_code" },
{ "code_verifier", challenge },
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
}));
// Assert
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
// they can decrypt with either option
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = responseBody.RootElement;
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
// Expected to look like:
// "UserDecryptionOptions": {
// "Object": "userDecryptionOptions"
// "HasMasterPassword": true,
// "TrustedDeviceOption": {
// "HasAdminApproval": false
// }
// }
// Should have master password & one for trusted device with admin approval
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
}
[Fact]
public async Task SsoLogin_TrustedDeviceEncryption_WithAdminResetPolicy_ReturnsOptions()
{
// Arrange
var challenge = new string('c', 50);
var factory = await CreateFactoryAsync(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
}, challenge);
var database = factory.GetDatabaseContext();
var organization = await database.Organizations.SingleAsync();
var policyRepository = factory.Services.GetRequiredService<IPolicyRepository>();
await policyRepository.CreateAsync(new Policy
{
Type = PolicyType.ResetPassword,
Enabled = true,
Data = "{\"autoEnrollEnabled\": false }",
OrganizationId = organization.Id,
});
// Act
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", "10" },
{ "deviceIdentifier", "test_id" },
{ "deviceName", "firefox" },
{ "twoFactorToken", "TEST"},
{ "twoFactorProvider", "5" }, // RememberMe Provider
{ "twoFactorRemember", "0" },
{ "grant_type", "authorization_code" },
{ "code", "test_code" },
{ "code_verifier", challenge },
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
}));
// Assert
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
// they can decrypt with either option
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = responseBody.RootElement;
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
// Expected to look like:
// "UserDecryptionOptions": {
// "Object": "userDecryptionOptions"
// "HasMasterPassword": true,
// "TrustedDeviceOption": {
// "HasAdminApproval": true
// }
// }
// Should have one item for master password & one for trusted device with admin approval
AssertHelper.AssertJsonProperty(userDecryptionOptions, "HasMasterPassword", JsonValueKind.True);
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.True);
}
[Fact]
public async Task SsoLogin_TrustedDeviceEncryptionAndNoMasterPassword_ReturnsOneOption()
{
// Arrange
var challenge = new string('c', 50);
var factory = await CreateFactoryAsync(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption,
}, challenge);
await UpdateUserAsync(factory, user => user.MasterPassword = null);
// Act
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", "10" },
{ "deviceIdentifier", "test_id" },
{ "deviceName", "firefox" },
{ "twoFactorToken", "TEST"},
{ "twoFactorProvider", "5" }, // RememberMe Provider
{ "twoFactorRemember", "0" },
{ "grant_type", "authorization_code" },
{ "code", "test_code" },
{ "code_verifier", challenge },
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
}));
// Assert
// If the organization has selected TrustedDeviceEncryption but the user still has their master password
// they can decrypt with either option
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = responseBody.RootElement;
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
// Expected to look like:
// "UserDecryptionOptions": {
// "Object": "userDecryptionOptions"
// "HasMasterPassword": false,
// "TrustedDeviceOption": {
// "HasAdminApproval": true
// }
// }
var trustedDeviceOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "TrustedDeviceOption", JsonValueKind.Object);
AssertHelper.AssertJsonProperty(trustedDeviceOption, "HasAdminApproval", JsonValueKind.False);
}
[Fact]
public async Task SsoLogin_KeyConnector_ReturnsOptions()
{
// Arrange
var challenge = new string('c', 50);
var factory = await CreateFactoryAsync(new SsoConfigurationData
{
MemberDecryptionType = MemberDecryptionType.KeyConnector,
KeyConnectorUrl = "https://key_connector.com"
}, challenge);
await UpdateUserAsync(factory, user => user.MasterPassword = null);
// Act
var context = await factory.Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", "10" },
{ "deviceIdentifier", "test_id" },
{ "deviceName", "firefox" },
{ "twoFactorToken", "TEST"},
{ "twoFactorProvider", "5" }, // RememberMe Provider
{ "twoFactorRemember", "0" },
{ "grant_type", "authorization_code" },
{ "code", "test_code" },
{ "code_verifier", challenge },
{ "redirect_uri", "https://localhost:8080/sso-connector.html" }
}));
// Assert
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
using var responseBody = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = responseBody.RootElement;
AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String);
var userDecryptionOptions = AssertHelper.AssertJsonProperty(root, "UserDecryptionOptions", JsonValueKind.Object);
// Expected to look like:
// "UserDecryptionOptions": {
// "Object": "userDecryptionOptions"
// "KeyConnectorOption": {
// "KeyConnectorUrl": "https://key_connector.com"
// }
// }
var keyConnectorOption = AssertHelper.AssertJsonProperty(userDecryptionOptions, "KeyConnectorOption", JsonValueKind.Object);
var keyConnectorUrl = AssertHelper.AssertJsonProperty(keyConnectorOption, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
// For backwards compatibility reasons the url should also be on the root
keyConnectorUrl = AssertHelper.AssertJsonProperty(root, "KeyConnectorUrl", JsonValueKind.String).GetString();
Assert.Equal("https://key_connector.com", keyConnectorUrl);
}
private static async Task<IdentityApplicationFactory> CreateFactoryAsync(SsoConfigurationData ssoConfigurationData, string challenge)
{
var factory = new IdentityApplicationFactory();
var authorizationCode = new AuthorizationCode
{
ClientId = "web",
CreationTime = DateTime.UtcNow,
Lifetime = (int)TimeSpan.FromMinutes(5).TotalSeconds,
RedirectUri = "https://localhost:8080/sso-connector.html",
RequestedScopes = new[] { "api", "offline_access" },
CodeChallenge = challenge.Sha256(),
CodeChallengeMethod = "plain", //
Subject = null, // Temporarily set it to null
};
factory.SubstitueService<IAuthorizationCodeStore>(service =>
{
service.GetAuthorizationCodeAsync("test_code")
.Returns(authorizationCode);
});
factory.SubstitueService<IFeatureService>(service =>
{
service.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, Arg.Any<ICurrentContext>())
.Returns(true);
});
// This starts the server and finalizes services
var registerResponse = await factory.RegisterAsync(new RegisterRequestModel
{
Email = TestEmail,
MasterPasswordHash = "master_password_hash",
});
var userRepository = factory.Services.GetRequiredService<IUserRepository>();
var user = await userRepository.GetByEmailAsync(TestEmail);
var organizationRepository = factory.Services.GetRequiredService<IOrganizationRepository>();
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
});
var organizationUserRepository = factory.Services.GetRequiredService<IOrganizationUserRepository>();
var organizationUser = await organizationUserRepository.CreateAsync(new OrganizationUser
{
UserId = user.Id,
OrganizationId = organization.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User,
});
var ssoConfigRepository = factory.Services.GetRequiredService<ISsoConfigRepository>();
await ssoConfigRepository.CreateAsync(new SsoConfig
{
OrganizationId = organization.Id,
Enabled = true,
Data = JsonSerializer.Serialize(ssoConfigurationData, JsonHelpers.CamelCase),
});
var subject = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(JwtClaimTypes.Subject, user.Id.ToString()), // Get real user id
new Claim(JwtClaimTypes.Name, TestEmail),
new Claim(JwtClaimTypes.IdentityProvider, "sso"),
new Claim("organizationId", organization.Id.ToString()),
new Claim(JwtClaimTypes.SessionId, "SOMETHING"),
new Claim(JwtClaimTypes.AuthenticationMethod, "external"),
new Claim(JwtClaimTypes.AuthenticationTime, DateTime.UtcNow.AddMinutes(-1).ToEpochTime().ToString())
}, "IdentityServer4", JwtClaimTypes.Name, JwtClaimTypes.Role));
authorizationCode.Subject = subject;
return factory;
}
private static async Task UpdateUserAsync(IdentityApplicationFactory factory, Action<User> changeUser)
{
var userRepository = factory.Services.GetRequiredService<IUserRepository>();
var user = await userRepository.GetByEmailAsync(TestEmail);
changeUser(user);
await userRepository.ReplaceAsync(user);
}
}

View File

@ -12,6 +12,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NoopRepos = Bit.Core.Repositories.Noop; using NoopRepos = Bit.Core.Repositories.Noop;
namespace Bit.IntegrationTestCommon.Factories; namespace Bit.IntegrationTestCommon.Factories;
@ -33,6 +34,23 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
/// </remarks> /// </remarks>
public string DatabaseName { get; set; } = Guid.NewGuid().ToString(); public string DatabaseName { get; set; } = Guid.NewGuid().ToString();
private readonly List<Action<IServiceCollection>> _configureTestServices = new();
public void SubstitueService<TService>(Action<TService> mockService)
where TService : class
{
_configureTestServices.Add(services =>
{
var foundServiceDescriptor = services.FirstOrDefault(sd => sd.ServiceType == typeof(TService))
?? throw new InvalidOperationException($"Could not find service of type {typeof(TService).FullName} to substitute");
services.Remove(foundServiceDescriptor);
var substitutedService = Substitute.For<TService>();
mockService(substitutedService);
services.Add(ServiceDescriptor.Singleton(typeof(TService), substitutedService));
});
}
/// <summary> /// <summary>
/// Configure the web host to use an EF in memory database /// Configure the web host to use an EF in memory database
/// </summary> /// </summary>
@ -146,6 +164,11 @@ public abstract class WebApplicationFactoryBase<T> : WebApplicationFactory<T>
// Disable logs // Disable logs
services.AddSingleton<ILoggerFactory, NullLoggerFactory>(); services.AddSingleton<ILoggerFactory, NullLoggerFactory>();
}); });
foreach (var configureTestService in _configureTestServices)
{
builder.ConfigureTestServices(configureTestService);
}
} }
public DatabaseContext GetDatabaseContext() public DatabaseContext GetDatabaseContext()