diff --git a/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs
new file mode 100644
index 000000000..bcafc0e89
--- /dev/null
+++ b/src/Core/Auth/Enums/WebAuthnLoginAssertionOptionsScope.cs
@@ -0,0 +1,7 @@
+namespace Bit.Core.Auth.Enums;
+
+public enum WebAuthnLoginAssertionOptionsScope
+{
+ Authentication = 0,
+ PrfRegistration = 1
+}
diff --git a/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs
new file mode 100644
index 000000000..6a0641246
--- /dev/null
+++ b/src/Core/Auth/Models/Api/Response/Accounts/WebAuthnLoginAssertionOptionsResponseModel.cs
@@ -0,0 +1,18 @@
+
+using Bit.Core.Models.Api;
+using Fido2NetLib;
+
+namespace Bit.Core.Auth.Models.Api.Response.Accounts;
+
+public class WebAuthnLoginAssertionOptionsResponseModel : ResponseModel
+{
+ private const string ResponseObj = "webAuthnLoginAssertionOptions";
+
+ public WebAuthnLoginAssertionOptionsResponseModel() : base(ResponseObj)
+ {
+ }
+
+ public AssertionOptions Options { get; set; }
+ public string Token { get; set; }
+}
+
diff --git a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs
index edfcce5a5..06990afea 100644
--- a/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs
+++ b/src/Core/Auth/Models/Api/Response/UserDecryptionOptions.cs
@@ -16,6 +16,12 @@ public class UserDecryptionOptions : ResponseModel
///
public bool HasMasterPassword { get; set; }
+ ///
+ /// Gets or sets the WebAuthn PRF decryption keys.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public WebAuthnPrfDecryptionOption? WebAuthnPrfOption { get; set; }
+
///
/// Gets or sets information regarding this users trusted device decryption setup.
///
@@ -29,6 +35,20 @@ public class UserDecryptionOptions : ResponseModel
public KeyConnectorUserDecryptionOption? KeyConnectorOption { get; set; }
}
+public class WebAuthnPrfDecryptionOption
+{
+ public string EncryptedPrivateKey { get; }
+ public string EncryptedUserKey { get; }
+
+ public WebAuthnPrfDecryptionOption(
+ string encryptedPrivateKey,
+ string encryptedUserKey)
+ {
+ EncryptedPrivateKey = encryptedPrivateKey;
+ EncryptedUserKey = encryptedUserKey;
+ }
+}
+
public class TrustedDeviceUserDecryptionOption
{
public bool HasAdminApproval { get; }
diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs
new file mode 100644
index 000000000..017033b00
--- /dev/null
+++ b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenable.cs
@@ -0,0 +1,47 @@
+using System.Text.Json.Serialization;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Tokens;
+using Fido2NetLib;
+
+namespace Bit.Core.Auth.Models.Business.Tokenables;
+
+public class WebAuthnLoginAssertionOptionsTokenable : ExpiringTokenable
+{
+ // Lifetime 17 minutes =
+ // - 6 Minutes for Attestation (max webauthn timeout)
+ // - 6 Minutes for PRF Assertion (max webauthn timeout)
+ // - 5 minutes for user to complete the process (name their passkey, etc)
+ private static readonly TimeSpan _tokenLifetime = TimeSpan.FromMinutes(17);
+ public const string ClearTextPrefix = "BWWebAuthnLoginAssertionOptions_";
+ public const string DataProtectorPurpose = "WebAuthnLoginAssertionOptionsDataProtector";
+ public const string TokenIdentifier = "WebAuthnLoginAssertionOptionsToken";
+
+ public string Identifier { get; set; } = TokenIdentifier;
+ public AssertionOptions Options { get; set; }
+ public WebAuthnLoginAssertionOptionsScope Scope { get; set; }
+
+ [JsonConstructor]
+ public WebAuthnLoginAssertionOptionsTokenable()
+ {
+ ExpirationDate = DateTime.UtcNow.Add(_tokenLifetime);
+ }
+
+ public WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions options) : this()
+ {
+ Scope = scope;
+ Options = options;
+ }
+
+ public bool TokenIsValid(WebAuthnLoginAssertionOptionsScope scope)
+ {
+ if (!Valid)
+ {
+ return false;
+ }
+
+ return Scope == scope;
+ }
+
+ protected override bool TokenIsValid() => Identifier == TokenIdentifier && Options != null;
+}
+
diff --git a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs
deleted file mode 100644
index b27b1fb35..000000000
--- a/src/Core/Auth/Models/Business/Tokenables/WebAuthnLoginTokenable.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.Text.Json.Serialization;
-using Bit.Core.Entities;
-using Bit.Core.Tokens;
-
-namespace Bit.Core.Auth.Models.Business.Tokenables;
-
-public class WebAuthnLoginTokenable : ExpiringTokenable
-{
- private const double _tokenLifetimeInHours = (double)1 / 60; // 1 minute
- public const string ClearTextPrefix = "BWWebAuthnLogin_";
- public const string DataProtectorPurpose = "WebAuthnLoginDataProtector";
- public const string TokenIdentifier = "WebAuthnLoginToken";
-
- public string Identifier { get; set; } = TokenIdentifier;
- public Guid Id { get; set; }
- public string Email { get; set; }
-
- [JsonConstructor]
- public WebAuthnLoginTokenable()
- {
- ExpirationDate = DateTime.UtcNow.AddHours(_tokenLifetimeInHours);
- }
-
- public WebAuthnLoginTokenable(User user) : this()
- {
- Id = user?.Id ?? default;
- Email = user?.Email;
- }
-
- public bool TokenIsValid(User user)
- {
- if (Id == default || Email == default || user == null)
- {
- return false;
- }
-
- return Id == user.Id &&
- Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase);
- }
-
- // Validates deserialized
- protected override bool TokenIsValid() => Identifier == TokenIdentifier && Id != default && !string.IsNullOrWhiteSpace(Email);
-}
diff --git a/src/Core/Auth/Utilities/GuidUtilities.cs b/src/Core/Auth/Utilities/GuidUtilities.cs
new file mode 100644
index 000000000..043e326b1
--- /dev/null
+++ b/src/Core/Auth/Utilities/GuidUtilities.cs
@@ -0,0 +1,19 @@
+namespace Bit.Core.Auth.Utilities;
+
+public static class GuidUtilities
+{
+ public static bool TryParseBytes(ReadOnlySpan bytes, out Guid guid)
+ {
+ try
+ {
+ guid = new Guid(bytes);
+ return true;
+ }
+ catch
+ {
+ guid = Guid.Empty;
+ return false;
+ }
+ }
+}
+
diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs
index 208b43116..aea0b56fe 100644
--- a/src/Core/Constants.cs
+++ b/src/Core/Constants.cs
@@ -38,6 +38,11 @@ public static class Constants
/// regardless of whether there is a proration or not.
///
public const string AlwaysInvoice = "always_invoice";
+
+ ///
+ /// Used by IdentityServer to identify our own provider.
+ ///
+ public const string IdentityProvider = "bitwarden";
}
public static class TokenPurposes
diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs
index 14401548b..c9ccef661 100644
--- a/src/Core/Services/IUserService.cs
+++ b/src/Core/Services/IUserService.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Entities;
@@ -29,8 +30,8 @@ public interface IUserService
Task CompleteWebAuthRegistrationAsync(User user, int value, string name, AuthenticatorAttestationRawResponse attestationResponse);
Task StartWebAuthnLoginRegistrationAsync(User user);
Task CompleteWebAuthLoginRegistrationAsync(User user, string name, CredentialCreateOptions options, AuthenticatorAttestationRawResponse attestationResponse, bool supportsPrf, string encryptedUserKey = null, string encryptedPublicKey = null, string encryptedPrivateKey = null);
- Task StartWebAuthnLoginAssertionAsync(User user);
- Task CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user);
+ AssertionOptions StartWebAuthnLoginAssertion();
+ Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse);
Task SendEmailVerificationAsync(User user);
Task ConfirmEmailAsync(User user, string token);
Task InitiateEmailChangeAsync(User user, string newEmail);
diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs
index 95ad5ac4c..1d7f95f1f 100644
--- a/src/Core/Services/Implementations/UserService.cs
+++ b/src/Core/Services/Implementations/UserService.cs
@@ -6,6 +6,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
+using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -61,9 +62,8 @@ public class UserService : UserManager, IUserService, IDisposable
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IStripeSyncService _stripeSyncService;
- private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
- private readonly IDataProtectorTokenFactory _webAuthnLoginTokenizer;
private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory;
+ private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository;
public UserService(
IUserRepository userRepository,
@@ -96,8 +96,7 @@ public class UserService : UserManager, IUserService, IDisposable
IProviderUserRepository providerUserRepository,
IStripeSyncService stripeSyncService,
IDataProtectorTokenFactory orgUserInviteTokenDataFactory,
- IWebAuthnCredentialRepository webAuthnRepository,
- IDataProtectorTokenFactory webAuthnLoginTokenizer)
+ IWebAuthnCredentialRepository webAuthnRepository)
: base(
store,
optionsAccessor,
@@ -136,7 +135,6 @@ public class UserService : UserManager, IUserService, IDisposable
_stripeSyncService = stripeSyncService;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_webAuthnCredentialRepository = webAuthnRepository;
- _webAuthnLoginTokenizer = webAuthnLoginTokenizer;
}
public Guid? GetProperUserId(ClaimsPrincipal principal)
@@ -586,45 +584,33 @@ public class UserService : UserManager, IUserService, IDisposable
return true;
}
- public async Task StartWebAuthnLoginAssertionAsync(User user)
+ public AssertionOptions StartWebAuthnLoginAssertion()
{
- var provider = user.GetTwoFactorProvider(TwoFactorProviderType.WebAuthn);
- var existingKeys = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
- var existingCredentials = existingKeys
- .Select(k => new PublicKeyCredentialDescriptor(CoreHelpers.Base64UrlDecode(k.CredentialId)))
- .ToList();
-
- if (existingCredentials.Count == 0)
- {
- return null;
- }
-
- // TODO: PRF?
- var exts = new AuthenticationExtensionsClientInputs
- {
- UserVerificationMethod = true
- };
- var options = _fido2.GetAssertionOptions(existingCredentials, UserVerificationRequirement.Required, exts);
-
- // TODO: temp save options to user record somehow
-
- return options;
+ return _fido2.GetAssertionOptions(Enumerable.Empty(), UserVerificationRequirement.Required);
}
- public async Task CompleteWebAuthLoginAssertionAsync(AuthenticatorAssertionRawResponse assertionResponse, User user)
+ public async Task<(User, WebAuthnCredential)> CompleteWebAuthLoginAssertionAsync(AssertionOptions options, AuthenticatorAssertionRawResponse assertionResponse)
{
- // TODO: Get options from user record somehow, then clear them
- var options = AssertionOptions.FromJson("");
-
- var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
- var assertionId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
- var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertionId);
- if (credential == null)
+ if (!GuidUtilities.TryParseBytes(assertionResponse.Response.UserHandle, out var userId))
{
- return null;
+ throw new BadRequestException("Invalid credential.");
}
- // TODO: Callback to ensure credential ID is unique. Do we care? I don't think so.
+ var user = await _userRepository.GetByIdAsync(userId);
+ if (user == null)
+ {
+ throw new BadRequestException("Invalid credential.");
+ }
+
+ var userCredentials = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id);
+ var assertedCredentialId = CoreHelpers.Base64UrlEncode(assertionResponse.Id);
+ var credential = userCredentials.FirstOrDefault(c => c.CredentialId == assertedCredentialId);
+ if (credential == null)
+ {
+ throw new BadRequestException("Invalid credential.");
+ }
+
+ // Always return true, since we've already filtered the credentials after user id
IsUserHandleOwnerOfCredentialIdAsync callback = (args, cancellationToken) => Task.FromResult(true);
var credentialPublicKey = CoreHelpers.Base64UrlDecode(credential.PublicKey);
var assertionVerificationResult = await _fido2.MakeAssertionAsync(
@@ -634,15 +620,12 @@ public class UserService : UserManager, IUserService, IDisposable
credential.Counter = (int)assertionVerificationResult.Counter;
await _webAuthnCredentialRepository.ReplaceAsync(credential);
- if (assertionVerificationResult.Status == "ok")
+ if (assertionVerificationResult.Status != "ok")
{
- var token = _webAuthnLoginTokenizer.Protect(new WebAuthnLoginTokenable(user));
- return token;
- }
- else
- {
- return null;
+ throw new BadRequestException("Invalid credential.");
}
+
+ return (user, credential);
}
public async Task SendEmailVerificationAsync(User user)
diff --git a/src/Identity/Controllers/AccountsController.cs b/src/Identity/Controllers/AccountsController.cs
index 9073884d8..a686afa53 100644
--- a/src/Identity/Controllers/AccountsController.cs
+++ b/src/Identity/Controllers/AccountsController.cs
@@ -1,6 +1,8 @@
using Bit.Core;
+using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Request.Accounts;
using Bit.Core.Auth.Models.Api.Response.Accounts;
+using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Auth.Utilities;
using Bit.Core.Enums;
@@ -8,9 +10,9 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
-using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Identity.Controllers;
@@ -23,17 +25,21 @@ public class AccountsController : Controller
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
+ private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector;
+
public AccountsController(
ILogger logger,
IUserRepository userRepository,
IUserService userService,
- ICaptchaValidationService captchaValidationService)
+ ICaptchaValidationService captchaValidationService,
+ IDataProtectorTokenFactory assertionOptionsDataProtector)
{
_logger = logger;
_userRepository = userRepository;
_userService = userService;
_captchaValidationService = captchaValidationService;
+ _assertionOptionsDataProtector = assertionOptionsDataProtector;
}
// Moved from API, If you modify this endpoint, please update API as well. Self hosted installs still use the API endpoints.
@@ -75,36 +81,19 @@ public class AccountsController : Controller
return new PreloginResponseModel(kdfInformation);
}
- [HttpPost("webauthn-assertion-options")]
- [ApiExplorerSettings(IgnoreApi = true)] // Disable Swagger due to CredentialCreateOptions not converting properly
+ [HttpPost("webauthn/assertion-options")]
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
- // TODO: Create proper models for this call
- public async Task PostWebAuthnAssertionOptions([FromBody] PreloginRequestModel model)
+ public WebAuthnLoginAssertionOptionsResponseModel PostWebAuthnLoginAssertionOptions()
{
- var user = await _userRepository.GetByEmailAsync(model.Email);
- if (user == null)
+ var options = _userService.StartWebAuthnLoginAssertion();
+
+ var tokenable = new WebAuthnLoginAssertionOptionsTokenable(WebAuthnLoginAssertionOptionsScope.Authentication, options);
+ var token = _assertionOptionsDataProtector.Protect(tokenable);
+
+ return new WebAuthnLoginAssertionOptionsResponseModel
{
- // TODO: return something? possible enumeration attacks with this response
- return new AssertionOptions();
- }
-
- var options = await _userService.StartWebAuthnLoginAssertionAsync(user);
- return options;
- }
-
- [HttpPost("webauthn-assertion")]
- [RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
- // TODO: Create proper models for this call
- public async Task PostWebAuthnAssertion([FromBody] PreloginRequestModel model)
- {
- var user = await _userRepository.GetByEmailAsync(model.Email);
- if (user == null)
- {
- // TODO: proper response here?
- throw new BadRequestException();
- }
-
- var token = await _userService.CompleteWebAuthLoginAssertionAsync(null, user);
- return token;
+ Options = options,
+ Token = token
+ };
}
}
diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs
index 8d2a294be..7457a8d0e 100644
--- a/src/Identity/IdentityServer/ApiClient.cs
+++ b/src/Identity/IdentityServer/ApiClient.cs
@@ -13,7 +13,7 @@ public class ApiClient : Client
string[] scopes = null)
{
ClientId = id;
- AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode };
+ AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType };
RefreshTokenExpiration = TokenExpiration.Sliding;
RefreshTokenUsage = TokenUsage.ReUse;
SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays;
diff --git a/src/Identity/IdentityServer/BaseRequestValidator.cs b/src/Identity/IdentityServer/BaseRequestValidator.cs
index d52d3064a..c01dc2230 100644
--- a/src/Identity/IdentityServer/BaseRequestValidator.cs
+++ b/src/Identity/IdentityServer/BaseRequestValidator.cs
@@ -10,7 +10,6 @@ using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
-using Bit.Core.Auth.Utilities;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -23,7 +22,6 @@ using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
-using Bit.Identity.Utilities;
using IdentityServer4.Validation;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
@@ -35,7 +33,6 @@ public abstract class BaseRequestValidator where T : class
private UserManager _userManager;
private readonly IDeviceRepository _deviceRepository;
private readonly IDeviceService _deviceService;
- private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IOrganizationDuoWebTokenProvider _organizationDuoWebTokenProvider;
private readonly IOrganizationRepository _organizationRepository;
@@ -53,6 +50,8 @@ public abstract class BaseRequestValidator where T : class
protected IPolicyService PolicyService { get; }
protected IFeatureService FeatureService { get; }
protected ISsoConfigRepository SsoConfigRepository { get; }
+ protected IUserService _userService { get; }
+ protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
public BaseRequestValidator(
UserManager userManager,
@@ -73,7 +72,8 @@ public abstract class BaseRequestValidator where T : class
IDataProtectorTokenFactory tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
- IDistributedCache distributedCache)
+ IDistributedCache distributedCache,
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
{
_userManager = userManager;
_deviceRepository = deviceRepository;
@@ -96,11 +96,12 @@ public abstract class BaseRequestValidator where T : class
_distributedCache = distributedCache;
_cacheEntryOptions = new DistributedCacheEntryOptions
{
- // This sets the time an item is cached to 15 minutes. This value is hard coded
- // to 15 because to it covers all time-out windows for both Authenticators and
+ // This sets the time an item is cached to 17 minutes. This value is hard coded
+ // to 17 because to it covers all time-out windows for both Authenticators and
// Email TOTP.
- AbsoluteExpirationRelativeToNow = new TimeSpan(0, 15, 0)
+ AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(17)
};
+ UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
}
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@@ -333,7 +334,7 @@ public abstract class BaseRequestValidator where T : class
protected abstract void SetErrorResult(T context, Dictionary customResponse);
protected abstract ClaimsPrincipal GetSubject(T context);
- private async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
+ protected virtual async Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
{
if (request.GrantType == "client_credentials")
{
@@ -612,67 +613,12 @@ public abstract class BaseRequestValidator where T : class
///
private async Task CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject)
{
- var ssoConfiguration = await GetSsoConfigurationDataAsync(subject);
-
- var userDecryptionOption = new UserDecryptionOptions
- {
- HasMasterPassword = !string.IsNullOrEmpty(user.MasterPassword)
- };
-
- var ssoConfigurationData = ssoConfiguration?.GetData();
-
- if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
- {
- // KeyConnector makes it mutually exclusive
- userDecryptionOption.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
- return userDecryptionOption;
- }
-
- // Only add the trusted device specific option when the flag is turned on
- if (FeatureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, CurrentContext) && ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
- {
- string? encryptedPrivateKey = null;
- string? encryptedUserKey = null;
- if (device.IsTrusted())
- {
- encryptedPrivateKey = device.EncryptedPrivateKey;
- encryptedUserKey = device.EncryptedUserKey;
- }
-
- var allDevices = await _deviceRepository.GetManyByUserIdAsync(user.Id);
- // Checks if the current user has any devices that are capable of approving login with device requests except for
- // their current device.
- // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
- var hasLoginApprovingDevice = allDevices
- .Where(d => d.Identifier != device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type))
- .Any();
-
- // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
- var hasManageResetPasswordPermission = false;
-
- // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
- if (CurrentContext.Organizations.Any(o => o.Id == ssoConfiguration!.OrganizationId))
- {
- // TDE requires single org so grabbing first org & id is fine.
- hasManageResetPasswordPermission = await CurrentContext.ManageResetPassword(ssoConfiguration!.OrganizationId);
- }
-
- // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
- var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(ssoConfiguration!.OrganizationId, user.Id);
-
- // They are only able to be approved by an admin if they have enrolled is reset password
- var hasAdminApproval = !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
-
- // TrustedDeviceEncryption only exists for SSO, but if that ever changes this value won't always be true
- userDecryptionOption.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(
- hasAdminApproval,
- hasLoginApprovingDevice,
- hasManageResetPasswordPermission,
- encryptedPrivateKey,
- encryptedUserKey);
- }
-
- return userDecryptionOption;
+ var ssoConfig = await GetSsoConfigurationDataAsync(subject);
+ return await UserDecryptionOptionsBuilder
+ .ForUser(user)
+ .WithDevice(device)
+ .WithSso(ssoConfig)
+ .BuildAsync();
}
private async Task GetSsoConfigurationDataAsync(ClaimsPrincipal subject)
diff --git a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs
index a15a73837..00f563154 100644
--- a/src/Identity/IdentityServer/CustomTokenRequestValidator.cs
+++ b/src/Identity/IdentityServer/CustomTokenRequestValidator.cs
@@ -44,12 +44,13 @@ public class CustomTokenRequestValidator : BaseRequestValidator tokenDataFactory,
IFeatureService featureService,
- IDistributedCache distributedCache)
+ IDistributedCache distributedCache,
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings,
userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository,
- distributedCache)
+ distributedCache, userDecryptionOptionsBuilder)
{
_userManager = userManager;
}
diff --git a/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs
new file mode 100644
index 000000000..dad9d8e27
--- /dev/null
+++ b/src/Identity/IdentityServer/IUserDecryptionOptionsBuilder.cs
@@ -0,0 +1,13 @@
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Models.Api.Response;
+using Bit.Core.Entities;
+
+namespace Bit.Identity.IdentityServer;
+public interface IUserDecryptionOptionsBuilder
+{
+ IUserDecryptionOptionsBuilder ForUser(User user);
+ IUserDecryptionOptionsBuilder WithDevice(Device device);
+ IUserDecryptionOptionsBuilder WithSso(SsoConfig ssoConfig);
+ IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential);
+ Task BuildAsync();
+}
diff --git a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs
index 8b8b0be52..52e39e64a 100644
--- a/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs
+++ b/src/Identity/IdentityServer/ResourceOwnerPasswordValidator.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
@@ -46,11 +47,12 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator tokenDataFactory,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
- IDistributedCache distributedCache)
+ IDistributedCache distributedCache,
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
: base(userManager, deviceRepository, deviceService, userService, eventService,
organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
applicationCacheService, mailService, logger, currentContext, globalSettings, userRepository, policyService,
- tokenDataFactory, featureService, ssoConfigRepository, distributedCache)
+ tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder)
{
_userManager = userManager;
_userService = userService;
@@ -144,7 +146,7 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator claims, Dictionary customResponse)
{
context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
- identityProvider: "bitwarden",
+ identityProvider: Constants.IdentityProvider,
claims: claims.Count > 0 ? claims : null,
customResponse: customResponse);
return Task.CompletedTask;
diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
new file mode 100644
index 000000000..4366ddbda
--- /dev/null
+++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs
@@ -0,0 +1,155 @@
+using Bit.Core;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Models.Api.Response;
+using Bit.Core.Auth.Utilities;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Identity.Utilities;
+
+namespace Bit.Identity.IdentityServer;
+
+#nullable enable
+///
+/// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents
+///
+/// Note: Do not use this as an injected service if you intend to build multiple independent UserDecryptionOptions
+///
+public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IFeatureService _featureService;
+ private readonly IDeviceRepository _deviceRepository;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+
+ private UserDecryptionOptions _options = new UserDecryptionOptions();
+ private User? _user;
+ private Core.Auth.Entities.SsoConfig? _ssoConfig;
+ private Device? _device;
+
+ public UserDecryptionOptionsBuilder(
+ ICurrentContext currentContext,
+ IFeatureService featureService,
+ IDeviceRepository deviceRepository,
+ IOrganizationUserRepository organizationUserRepository
+ )
+ {
+ _currentContext = currentContext;
+ _featureService = featureService;
+ _deviceRepository = deviceRepository;
+ _organizationUserRepository = organizationUserRepository;
+ }
+
+ public IUserDecryptionOptionsBuilder ForUser(User user)
+ {
+ _options.HasMasterPassword = user.HasMasterPassword();
+ _user = user;
+ return this;
+ }
+
+ public IUserDecryptionOptionsBuilder WithSso(Core.Auth.Entities.SsoConfig ssoConfig)
+ {
+ _ssoConfig = ssoConfig;
+ return this;
+ }
+
+ public IUserDecryptionOptionsBuilder WithDevice(Device device)
+ {
+ _device = device;
+ return this;
+ }
+
+ public IUserDecryptionOptionsBuilder WithWebAuthnLoginCredential(WebAuthnCredential credential)
+ {
+ if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
+ {
+ _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey);
+ }
+ return this;
+ }
+
+ public async Task BuildAsync()
+ {
+ BuildKeyConnectorOptions();
+ await BuildTrustedDeviceOptions();
+
+ return _options;
+ }
+
+ private void BuildKeyConnectorOptions()
+ {
+ if (_ssoConfig == null)
+ {
+ return;
+ }
+
+ var ssoConfigurationData = _ssoConfig.GetData();
+ if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl))
+ {
+ _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl);
+ }
+ }
+
+ private async Task BuildTrustedDeviceOptions()
+ {
+ // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change
+ if (_ssoConfig == null || !_featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext))
+ {
+ return;
+ }
+
+ var ssoConfigurationData = _ssoConfig.GetData();
+ if (ssoConfigurationData is not { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption })
+ {
+ return;
+ }
+
+ string? encryptedPrivateKey = null;
+ string? encryptedUserKey = null;
+ if (_device != null && _device.IsTrusted())
+ {
+ encryptedPrivateKey = _device.EncryptedPrivateKey;
+ encryptedUserKey = _device.EncryptedUserKey;
+ }
+
+ var hasLoginApprovingDevice = false;
+ if (_device != null && _user != null)
+ {
+ var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id);
+ // Checks if the current user has any devices that are capable of approving login with device requests except for
+ // their current device.
+ // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting.
+ hasLoginApprovingDevice = allDevices
+ .Where(d => d.Identifier != _device.Identifier && LoginApprovingDeviceTypes.Types.Contains(d.Type))
+ .Any();
+ }
+
+ // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP
+ var hasManageResetPasswordPermission = false;
+ // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here
+ if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId))
+ {
+ // TDE requires single org so grabbing first org & id is fine.
+ hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId);
+ }
+
+ var hasAdminApproval = false;
+ if (_user != null)
+ {
+ // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null
+ var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id);
+
+ // They are only able to be approved by an admin if they have enrolled is reset password
+ hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey);
+ }
+
+ _options.TrustedDeviceOption = new TrustedDeviceUserDecryptionOption(
+ hasAdminApproval,
+ hasLoginApprovingDevice,
+ hasManageResetPasswordPermission,
+ encryptedPrivateKey,
+ encryptedUserKey);
+ }
+}
diff --git a/src/Identity/IdentityServer/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs
new file mode 100644
index 000000000..8d3761d6c
--- /dev/null
+++ b/src/Identity/IdentityServer/WebAuthnGrantValidator.cs
@@ -0,0 +1,150 @@
+using System.Security.Claims;
+using System.Text.Json;
+using Bit.Core;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Identity;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Core.Auth.Repositories;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Core.Settings;
+using Bit.Core.Tokens;
+using Fido2NetLib;
+using IdentityServer4.Models;
+using IdentityServer4.Validation;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Bit.Identity.IdentityServer;
+
+public class WebAuthnGrantValidator : BaseRequestValidator, IExtensionGrantValidator
+{
+ public const string GrantType = "webauthn";
+
+ private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector;
+
+ public WebAuthnGrantValidator(
+ UserManager userManager,
+ IDeviceRepository deviceRepository,
+ IDeviceService deviceService,
+ IUserService userService,
+ IEventService eventService,
+ IOrganizationDuoWebTokenProvider organizationDuoWebTokenProvider,
+ IOrganizationRepository organizationRepository,
+ IOrganizationUserRepository organizationUserRepository,
+ IApplicationCacheService applicationCacheService,
+ IMailService mailService,
+ ILogger logger,
+ ICurrentContext currentContext,
+ GlobalSettings globalSettings,
+ ISsoConfigRepository ssoConfigRepository,
+ IUserRepository userRepository,
+ IPolicyService policyService,
+ IDataProtectorTokenFactory tokenDataFactory,
+ IDataProtectorTokenFactory assertionOptionsDataProtector,
+ IFeatureService featureService,
+ IDistributedCache distributedCache,
+ IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder
+ )
+ : base(userManager, deviceRepository, deviceService, userService, eventService,
+ organizationDuoWebTokenProvider, organizationRepository, organizationUserRepository,
+ applicationCacheService, mailService, logger, currentContext, globalSettings,
+ userRepository, policyService, tokenDataFactory, featureService, ssoConfigRepository, distributedCache, userDecryptionOptionsBuilder)
+ {
+ _assertionOptionsDataProtector = assertionOptionsDataProtector;
+ }
+
+ string IExtensionGrantValidator.GrantType => "webauthn";
+
+ public async Task ValidateAsync(ExtensionGrantValidationContext context)
+ {
+ if (!FeatureService.IsEnabled(FeatureFlagKeys.PasswordlessLogin, CurrentContext))
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
+ return;
+ }
+
+ var rawToken = context.Request.Raw.Get("token");
+ var rawDeviceResponse = context.Request.Raw.Get("deviceResponse");
+ if (string.IsNullOrWhiteSpace(rawToken) || string.IsNullOrWhiteSpace(rawDeviceResponse))
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
+ return;
+ }
+
+ var verified = _assertionOptionsDataProtector.TryUnprotect(rawToken, out var token) &&
+ token.TokenIsValid(WebAuthnLoginAssertionOptionsScope.Authentication);
+ var deviceResponse = JsonSerializer.Deserialize(rawDeviceResponse);
+
+ if (!verified)
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
+ return;
+ }
+
+ var (user, credential) = await _userService.CompleteWebAuthLoginAssertionAsync(token.Options, deviceResponse);
+ var validatorContext = new CustomValidatorRequestContext
+ {
+ User = user,
+ KnownDevice = await KnownDeviceAsync(user, context.Request)
+ };
+
+ UserDecryptionOptionsBuilder.WithWebAuthnLoginCredential(credential);
+
+ await ValidateAsync(context, context.Request, validatorContext);
+ }
+
+ protected override Task ValidateContextAsync(ExtensionGrantValidationContext context,
+ CustomValidatorRequestContext validatorContext)
+ {
+ if (validatorContext.User == null)
+ {
+ return Task.FromResult(false);
+ }
+
+ return Task.FromResult(true);
+ }
+
+ protected override Task SetSuccessResult(ExtensionGrantValidationContext context, User user,
+ List claims, Dictionary customResponse)
+ {
+ context.Result = new GrantValidationResult(user.Id.ToString(), "Application",
+ identityProvider: Constants.IdentityProvider,
+ claims: claims.Count > 0 ? claims : null,
+ customResponse: customResponse);
+ return Task.CompletedTask;
+ }
+
+ protected override ClaimsPrincipal GetSubject(ExtensionGrantValidationContext context)
+ {
+ return context.Result.Subject;
+ }
+
+ protected override Task> RequiresTwoFactorAsync(User user, ValidatedTokenRequest request)
+ {
+ // We consider Fido2 userVerification a second factor, so we don't require a second factor here.
+ return Task.FromResult(new Tuple(false, null));
+ }
+
+ protected override void SetTwoFactorResult(ExtensionGrantValidationContext context,
+ Dictionary customResponse)
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Two factor required.",
+ customResponse);
+ }
+
+ protected override void SetSsoResult(ExtensionGrantValidationContext context,
+ Dictionary customResponse)
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Sso authentication required.",
+ customResponse);
+ }
+
+ protected override void SetErrorResult(ExtensionGrantValidationContext context,
+ Dictionary customResponse)
+ {
+ context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, customResponse: customResponse);
+ }
+}
diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs
index 068e12bd4..53b9c49e9 100644
--- a/src/Identity/Utilities/ServiceCollectionExtensions.cs
+++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs
@@ -17,6 +17,7 @@ public static class ServiceCollectionExtensions
services.AddSingleton();
services.AddTransient();
+ services.AddTransient();
var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity);
var identityServerBuilder = services
@@ -44,7 +45,8 @@ public static class ServiceCollectionExtensions
.AddResourceOwnerValidator()
.AddPersistedGrantStore()
.AddClientStore()
- .AddIdentityServerCertificate(env, globalSettings);
+ .AddIdentityServerCertificate(env, globalSettings)
+ .AddExtensionGrantValidator();
services.AddTransient();
return identityServerBuilder;
diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
index c12c849e3..2a2a06efe 100644
--- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
+++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs
@@ -168,18 +168,18 @@ public static class ServiceCollectionExtensions
SsoTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService>>()));
- services.AddSingleton>(serviceProvider =>
- new DataProtectorTokenFactory(
- WebAuthnLoginTokenable.ClearTextPrefix,
- WebAuthnLoginTokenable.DataProtectorPurpose,
- serviceProvider.GetDataProtectionProvider(),
- serviceProvider.GetRequiredService>>()));
services.AddSingleton>(serviceProvider =>
new DataProtectorTokenFactory(
WebAuthnCredentialCreateOptionsTokenable.ClearTextPrefix,
WebAuthnCredentialCreateOptionsTokenable.DataProtectorPurpose,
serviceProvider.GetDataProtectionProvider(),
serviceProvider.GetRequiredService>>()));
+ services.AddSingleton>(serviceProvider =>
+ new DataProtectorTokenFactory(
+ WebAuthnLoginAssertionOptionsTokenable.ClearTextPrefix,
+ WebAuthnLoginAssertionOptionsTokenable.DataProtectorPurpose,
+ serviceProvider.GetDataProtectionProvider(),
+ serviceProvider.GetRequiredService>>()));
services.AddSingleton>(serviceProvider =>
new DataProtectorTokenFactory(
SsoEmail2faSessionTokenable.ClearTextPrefix,
diff --git a/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs
new file mode 100644
index 000000000..3978fef0f
--- /dev/null
+++ b/test/Core.Test/Auth/Models/Business/Tokenables/WebAuthnLoginAssertionOptionsTokenableTests.cs
@@ -0,0 +1,61 @@
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Models.Business.Tokenables;
+using Bit.Test.Common.AutoFixture.Attributes;
+using Fido2NetLib;
+using Xunit;
+
+namespace Bit.Core.Test.Auth.Models.Business.Tokenables;
+
+public class WebAuthnLoginAssertionOptionsTokenableTests
+{
+ [Theory, BitAutoData]
+ public void Valid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope)
+ {
+ var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null);
+
+ var isValid = token.Valid;
+
+ Assert.False(isValid);
+ }
+
+ [Theory, BitAutoData]
+ public void Valid_NewlyCreatedToken_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions)
+ {
+ var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions);
+
+
+ var isValid = token.Valid;
+
+ Assert.True(isValid);
+ }
+
+ [Theory, BitAutoData]
+ public void ValidIsValid_TokenWithoutOptions_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope)
+ {
+ var token = new WebAuthnLoginAssertionOptionsTokenable(scope, null);
+
+ var isValid = token.TokenIsValid(scope);
+
+ Assert.False(isValid);
+ }
+
+ [Theory, BitAutoData]
+ public void ValidIsValid_NonMatchingScope_ReturnsFalse(WebAuthnLoginAssertionOptionsScope scope1, WebAuthnLoginAssertionOptionsScope scope2, AssertionOptions createOptions)
+ {
+ var token = new WebAuthnLoginAssertionOptionsTokenable(scope1, createOptions);
+
+ var isValid = token.TokenIsValid(scope2);
+
+ Assert.False(isValid);
+ }
+
+ [Theory, BitAutoData]
+ public void ValidIsValid_SameScope_ReturnsTrue(WebAuthnLoginAssertionOptionsScope scope, AssertionOptions createOptions)
+ {
+ var token = new WebAuthnLoginAssertionOptionsTokenable(scope, createOptions);
+
+ var isValid = token.TokenIsValid(scope);
+
+ Assert.True(isValid);
+ }
+}
diff --git a/test/Core.Test/Services/UserServiceTests.cs b/test/Core.Test/Services/UserServiceTests.cs
index 724532f3c..c3bd1b283 100644
--- a/test/Core.Test/Services/UserServiceTests.cs
+++ b/test/Core.Test/Services/UserServiceTests.cs
@@ -1,4 +1,5 @@
-using System.Text.Json;
+using System.Text;
+using System.Text.Json;
using AutoFixture;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Entities;
@@ -8,26 +9,29 @@ using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
+using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
-using Bit.Core.Tokens;
using Bit.Core.Tools.Services;
+using Bit.Core.Utilities;
using Bit.Core.Vault.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Fakes;
using Bit.Test.Common.Helpers;
using Fido2NetLib;
+using Fido2NetLib.Objects;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ReceivedExtensions;
+using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Core.Test.Services;
@@ -188,7 +192,7 @@ public class UserServiceTests
}
[Theory, BitAutoData]
- public async void CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator)
+ public async Task CompleteWebAuthLoginRegistrationAsync_ExceedsExistingCredentialsLimit_ReturnsFalse(SutProvider sutProvider, User user, CredentialCreateOptions options, AuthenticatorAttestationRawResponse response, Generator credentialGenerator)
{
// Arrange
var existingCredentials = credentialGenerator.Take(5).ToList();
@@ -202,6 +206,92 @@ public class UserServiceTests
sutProvider.GetDependency().DidNotReceive();
}
+ [Theory, BitAutoData]
+ public async Task CompleteWebAuthLoginAssertionAsync_InvalidUserHandle_ThrowsBadRequestException(SutProvider sutProvider, AssertionOptions options, AuthenticatorAssertionRawResponse response)
+ {
+ // Arrange
+ response.Response.UserHandle = Encoding.UTF8.GetBytes("invalid-user-handle");
+
+ // Act
+ var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
+
+ // Assert
+ await Assert.ThrowsAsync(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task CompleteWebAuthLoginAssertionAsync_UserNotFound_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
+ {
+ // Arrange
+ response.Response.UserHandle = user.Id.ToByteArray();
+ sutProvider.GetDependency().GetByIdAsync(user.Id).ReturnsNull();
+
+ // Act
+ var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
+
+ // Assert
+ await Assert.ThrowsAsync(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task CompleteWebAuthLoginAssertionAsync_NoMatchingCredentialExists_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response)
+ {
+ // Arrange
+ response.Response.UserHandle = user.Id.ToByteArray();
+ sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user);
+ sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { });
+
+ // Act
+ var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
+
+ // Assert
+ await Assert.ThrowsAsync(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task CompleteWebAuthLoginAssertionAsync_AssertionFails_ThrowsBadRequestException(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
+ {
+ // Arrange
+ var credentialId = Guid.NewGuid().ToByteArray();
+ credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
+ response.Id = credentialId;
+ response.Response.UserHandle = user.Id.ToByteArray();
+ assertionResult.Status = "Not ok";
+ sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user);
+ sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
+ sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(assertionResult);
+
+ // Act
+ var result = async () => await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
+
+ // Assert
+ await Assert.ThrowsAsync(result);
+ }
+
+ [Theory, BitAutoData]
+ public async Task CompleteWebAuthLoginAssertionAsync_AssertionSucceeds_ReturnsUserAndCredential(SutProvider sutProvider, User user, AssertionOptions options, AuthenticatorAssertionRawResponse response, WebAuthnCredential credential, AssertionVerificationResult assertionResult)
+ {
+ // Arrange
+ var credentialId = Guid.NewGuid().ToByteArray();
+ credential.CredentialId = CoreHelpers.Base64UrlEncode(credentialId);
+ response.Id = credentialId;
+ response.Response.UserHandle = user.Id.ToByteArray();
+ assertionResult.Status = "ok";
+ sutProvider.GetDependency().GetByIdAsync(user.Id).Returns(user);
+ sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new WebAuthnCredential[] { credential });
+ sutProvider.GetDependency().MakeAssertionAsync(response, options, Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(assertionResult);
+
+ // Act
+ var result = await sutProvider.Sut.CompleteWebAuthLoginAssertionAsync(options, response);
+
+ // Assert
+ var (userResult, credentialResult) = result;
+ Assert.Equal(user, userResult);
+ Assert.Equal(credential, credentialResult);
+ }
+
[Flags]
public enum ShouldCheck
{
@@ -278,8 +368,7 @@ public class UserServiceTests
sutProvider.GetDependency(),
sutProvider.GetDependency(),
new FakeDataProtectorTokenFactory(),
- sutProvider.GetDependency(),
- sutProvider.GetDependency>()
+ sutProvider.GetDependency()
);
var actualIsVerified = await sut.VerifySecretAsync(user, secret);
diff --git a/test/Identity.IntegrationTest/openid-configuration.json b/test/Identity.IntegrationTest/openid-configuration.json
index 9442330da..97e8105f4 100644
--- a/test/Identity.IntegrationTest/openid-configuration.json
+++ b/test/Identity.IntegrationTest/openid-configuration.json
@@ -38,7 +38,8 @@
"refresh_token",
"implicit",
"password",
- "urn:ietf:params:oauth:grant-type:device_code"
+ "urn:ietf:params:oauth:grant-type:device_code",
+ "webauthn"
],
"response_types_supported": [
"code",
@@ -49,24 +50,13 @@
"code token",
"code id_token token"
],
- "response_modes_supported": [
- "form_post",
- "query",
- "fragment"
- ],
+ "response_modes_supported": ["form_post", "query", "fragment"],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
- "id_token_signing_alg_values_supported": [
- "RS256"
- ],
- "subject_types_supported": [
- "public"
- ],
- "code_challenge_methods_supported": [
- "plain",
- "S256"
- ],
+ "id_token_signing_alg_values_supported": ["RS256"],
+ "subject_types_supported": ["public"],
+ "code_challenge_methods_supported": ["plain", "S256"],
"request_parameter_supported": true
}
diff --git a/test/Identity.Test/Controllers/AccountsControllerTests.cs b/test/Identity.Test/Controllers/AccountsControllerTests.cs
index 32473593d..3d94fd54b 100644
--- a/test/Identity.Test/Controllers/AccountsControllerTests.cs
+++ b/test/Identity.Test/Controllers/AccountsControllerTests.cs
@@ -1,4 +1,5 @@
using Bit.Core.Auth.Models.Api.Request.Accounts;
+using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -6,6 +7,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
+using Bit.Core.Tokens;
using Bit.Identity.Controllers;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
@@ -22,6 +24,7 @@ public class AccountsControllerTests : IDisposable
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly ICaptchaValidationService _captchaValidationService;
+ private readonly IDataProtectorTokenFactory _assertionOptionsDataProtector;
public AccountsControllerTests()
{
@@ -29,11 +32,13 @@ public class AccountsControllerTests : IDisposable
_userRepository = Substitute.For();
_userService = Substitute.For();
_captchaValidationService = Substitute.For();
+ _assertionOptionsDataProtector = Substitute.For>();
_sut = new AccountsController(
_logger,
_userRepository,
_userService,
- _captchaValidationService
+ _captchaValidationService,
+ _assertionOptionsDataProtector
);
}
diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs
new file mode 100644
index 000000000..604c7709b
--- /dev/null
+++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs
@@ -0,0 +1,172 @@
+using Bit.Core;
+using Bit.Core.Auth.Entities;
+using Bit.Core.Auth.Enums;
+using Bit.Core.Auth.Models.Data;
+using Bit.Core.Context;
+using Bit.Core.Entities;
+using Bit.Core.Repositories;
+using Bit.Core.Services;
+using Bit.Identity.IdentityServer;
+using Bit.Identity.Utilities;
+using Bit.Test.Common.AutoFixture.Attributes;
+using NSubstitute;
+using Xunit;
+
+namespace Bit.Identity.Test.IdentityServer;
+
+public class UserDecryptionOptionsBuilderTests
+{
+ private readonly ICurrentContext _currentContext;
+ private readonly IFeatureService _featureService;
+ private readonly IDeviceRepository _deviceRepository;
+ private readonly IOrganizationUserRepository _organizationUserRepository;
+ private readonly UserDecryptionOptionsBuilder _builder;
+
+ public UserDecryptionOptionsBuilderTests()
+ {
+ _currentContext = Substitute.For();
+ _featureService = Substitute.For();
+ _deviceRepository = Substitute.For();
+ _organizationUserRepository = Substitute.For();
+ _builder = new UserDecryptionOptionsBuilder(_currentContext, _featureService, _deviceRepository, _organizationUserRepository);
+ }
+
+ [Theory]
+ [BitAutoData(true, true, true)] // All keys are non-null
+ [BitAutoData(false, false, false)] // All keys are null
+ [BitAutoData(false, false, true)] // EncryptedUserKey is non-null, others are null
+ [BitAutoData(false, true, false)] // EncryptedPublicKey is non-null, others are null
+ [BitAutoData(true, false, false)] // EncryptedPrivateKey is non-null, others are null
+ [BitAutoData(true, false, true)] // EncryptedPrivateKey and EncryptedUserKey are non-null, EncryptedPublicKey is null
+ [BitAutoData(true, true, false)] // EncryptedPrivateKey and EncryptedPublicKey are non-null, EncryptedUserKey is null
+ [BitAutoData(false, true, true)] // EncryptedPublicKey and EncryptedUserKey are non-null, EncryptedPrivateKey is null
+ public async Task WithWebAuthnLoginCredential_VariousKeyCombinations_ShouldReturnCorrectPrfOption(
+ bool hasEncryptedPrivateKey,
+ bool hasEncryptedPublicKey,
+ bool hasEncryptedUserKey,
+ WebAuthnCredential credential)
+ {
+ credential.EncryptedPrivateKey = hasEncryptedPrivateKey ? "encryptedPrivateKey" : null;
+ credential.EncryptedPublicKey = hasEncryptedPublicKey ? "encryptedPublicKey" : null;
+ credential.EncryptedUserKey = hasEncryptedUserKey ? "encryptedUserKey" : null;
+
+ var result = await _builder.WithWebAuthnLoginCredential(credential).BuildAsync();
+
+ if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled)
+ {
+ Assert.NotNull(result.WebAuthnPrfOption);
+ Assert.Equal(credential.EncryptedPrivateKey, result.WebAuthnPrfOption!.EncryptedPrivateKey);
+ Assert.Equal(credential.EncryptedUserKey, result.WebAuthnPrfOption!.EncryptedUserKey);
+ }
+ else
+ {
+ Assert.Null(result.WebAuthnPrfOption);
+ }
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenKeyConnectorIsEnabled_ShouldReturnKeyConnectorOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData)
+ {
+ configurationData.MemberDecryptionType = MemberDecryptionType.KeyConnector;
+ ssoConfig.Data = configurationData.Serialize();
+
+ var result = await _builder.WithSso(ssoConfig).BuildAsync();
+
+ Assert.NotNull(result.KeyConnectorOption);
+ Assert.Equal(configurationData.KeyConnectorUrl, result.KeyConnectorOption!.KeyConnectorUrl);
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenTrustedDeviceIsEnabled_ShouldReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+
+ var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync();
+
+ Assert.NotNull(result.TrustedDeviceOption);
+ Assert.False(result.TrustedDeviceOption!.HasAdminApproval);
+ Assert.False(result.TrustedDeviceOption!.HasLoginApprovingDevice);
+ Assert.False(result.TrustedDeviceOption!.HasManageResetPasswordPermission);
+ }
+
+ // TODO: Remove when FeatureFlagKeys.TrustedDeviceEncryption is removed
+ [Theory, BitAutoData]
+ public async Task Build_WhenTrustedDeviceIsEnabledButFeatureFlagIsDisabled_ShouldNotReturnTrustedDeviceOptions(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(false);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+
+ var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync();
+
+ Assert.Null(result.TrustedDeviceOption);
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenDeviceIsTrusted_ShouldReturnKeys(SsoConfig ssoConfig, SsoConfigurationData configurationData, Device device)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+ device.EncryptedPrivateKey = "encryptedPrivateKey";
+ device.EncryptedPublicKey = "encryptedPublicKey";
+ device.EncryptedUserKey = "encryptedUserKey";
+
+ var result = await _builder.WithSso(ssoConfig).WithDevice(device).BuildAsync();
+
+ Assert.Equal(device.EncryptedPrivateKey, result.TrustedDeviceOption?.EncryptedPrivateKey);
+ Assert.Equal(device.EncryptedUserKey, result.TrustedDeviceOption?.EncryptedUserKey);
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenHasLoginApprovingDevice_ShouldApprovingDeviceTrue(SsoConfig ssoConfig, SsoConfigurationData configurationData, User user, Device device, Device approvingDevice)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+ approvingDevice.Type = LoginApprovingDeviceTypes.Types.First();
+ _deviceRepository.GetManyByUserIdAsync(user.Id).Returns(new Device[] { approvingDevice });
+
+ var result = await _builder.ForUser(user).WithSso(ssoConfig).WithDevice(device).BuildAsync();
+
+ Assert.True(result.TrustedDeviceOption?.HasLoginApprovingDevice);
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue(
+ SsoConfig ssoConfig,
+ SsoConfigurationData configurationData,
+ CurrentContextOrganization organization)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+ ssoConfig.OrganizationId = organization.Id;
+ _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization }));
+ _currentContext.ManageResetPassword(organization.Id).Returns(true);
+
+ var result = await _builder.WithSso(ssoConfig).BuildAsync();
+
+ Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission);
+ }
+
+ [Theory, BitAutoData]
+ public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue(
+ SsoConfig ssoConfig,
+ SsoConfigurationData configurationData,
+ OrganizationUser organizationUser,
+ User user)
+ {
+ _featureService.IsEnabled(FeatureFlagKeys.TrustedDeviceEncryption, _currentContext).Returns(true);
+ configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
+ ssoConfig.Data = configurationData.Serialize();
+ organizationUser.ResetPasswordKey = "resetPasswordKey";
+ _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser);
+
+ var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync();
+
+ Assert.True(result.TrustedDeviceOption?.HasAdminApproval);
+ }
+}