Compare commits

...

8 Commits

Author SHA1 Message Date
addisonbeck
375af7c43b
set version to 2025.7.3 2025-08-05 13:27:33 -04:00
Vijay Oommen
8ae052039f
PM-24367 add personal_id to onyx api call (#6154)
(cherry picked from commit 5485c124454252d818c11bb1c0efd92675a2a4f9)
2025-08-04 13:50:58 -04:00
Github Actions
7090cc799d
Bumped version to 2025.8.0 2025-08-04 13:29:27 -04:00
Jared Snider
40f04ab832
pm-24210-v3 (#6148) + test merge conflict fix in base request validator tests 2025-07-31 11:12:31 -04:00
Jared Snider
ed42d4bec8
pm-24210-v2 (#6144) 2025-07-31 11:10:55 -04:00
Ike
963528106b
pm-24208 (#6143)
* pm-24208
2025-07-31 11:10:47 -04:00
Jared Snider
082acb7500
pm-24210 (#6142) 2025-07-31 11:10:37 -04:00
Alex Morask
86c1d642c9
Enable disabled provider on successful update payment method invocation (#6129) 2025-07-28 11:08:00 -05:00
23 changed files with 817 additions and 144 deletions

View File

@ -3,61 +3,40 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.7.2</Version>
<Version>2025.7.3</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<!--
This section is for packages that we use multiple times throughout the solution
It gives us a single place to manage the version to ensure we are using the same version
across the solution.
-->
<PropertyGroup>
<!--
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
-->
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitVersion>2.6.6</XUnitVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
-->
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<!--
NuGet: https://www.nuget.org/packages/coverlet.collector
-->
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<!--
NuGet: https://www.nuget.org/packages/NSubstitute
-->
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
-->
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
-->
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
<!--
This section is for getting & setting the gitHash value, which can easily be accessed
via the Core.Utilities.AssemblyHelpers class.
-->
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput" />
</Exec>
</Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">

View File

@ -255,9 +255,12 @@ public class AccountController : Controller
_logger.LogDebug("External claims: {@claims}", externalClaims);
// Lookup our user and external provider info
// Note: the user will only exist if the user has already been provisioned and exists in the User table and the SSO user table.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
if (user == null)
{
// User does not exist in SSO User table. They could have an existing BW account in the User table.
// This might be where you might initiate a custom workflow for user registration
// in this sample we don't show how that would be done, as our sample implementation
// simply auto-provisions new external user
@ -268,6 +271,8 @@ public class AccountController : Controller
if (user != null)
{
// User was JIT provisioned (this could be an existing user or a new user)
// This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols.
@ -487,12 +492,8 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
}
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is invited - they must manually accept the invite via email and authenticate with MP
// This allows us to enroll them in MP reset if required
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
}
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(),
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
// Accepted or Confirmed - create SSO link and return;
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
@ -587,6 +588,36 @@ public class AccountController : Controller
return user;
}
private void EnsureOrgUserStatusAllowed(
OrganizationUserStatusType status,
string organizationDisplayName,
params OrganizationUserStatusType[] allowedStatuses)
{
// if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status))
{
return;
}
// otherwise throw the appropriate exception
switch (status)
{
case OrganizationUserStatusType.Invited:
// Org User is invited must accept via email first
throw new Exception(
_i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName));
case OrganizationUserStatusType.Revoked:
// Revoked users may not be (auto)provisioned
throw new Exception(
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName));
default:
// anything else is “unknown”
throw new Exception(
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName));
}
}
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{
Response.StatusCode = ex == null ? 400 : 500;

View File

@ -1,8 +1,8 @@
#nullable enable
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Payment;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Utilities;
@ -19,6 +19,7 @@ public class ProviderBillingVNextController(
IGetBillingAddressQuery getBillingAddressQuery,
IGetCreditQuery getCreditQuery,
IGetPaymentMethodQuery getPaymentMethodQuery,
IProviderService providerService,
IUpdateBillingAddressCommand updateBillingAddressCommand,
IUpdatePaymentMethodCommand updatePaymentMethodCommand,
IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController
@ -82,6 +83,15 @@ public class ProviderBillingVNextController(
{
var (paymentMethod, billingAddress) = request.ToDomain();
var result = await updatePaymentMethodCommand.Run(provider, paymentMethod, billingAddress);
// TODO: Temporary until we can send Provider notifications from the Billing API
if (!provider.Enabled)
{
await result.TapAsync(async _ =>
{
provider.Enabled = true;
await providerService.UpdateAsync(provider);
});
}
return Handle(result);
}

View File

@ -40,5 +40,6 @@ public class BillingSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
public virtual int PersonaId { get; set; }
}
}

View File

@ -153,7 +153,7 @@ public class FreshdeskController : Controller
}
// create the onyx `answer-with-citation` request
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText);
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{

View File

@ -20,11 +20,12 @@ public class OnyxAnswerWithCitationRequestModel
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; }
public OnyxAnswerWithCitationRequestModel(string message)
public OnyxAnswerWithCitationRequestModel(string message, int personaId = 1)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
RetrievalOptions = new RetrievalOptions();
PersonaId = personaId;
}
}

View File

@ -31,5 +31,10 @@
"storage": {
"connectionString": "UseDevelopmentStorage=true"
}
},
"billingSettings": {
"onyx": {
"personaId": 68
}
}
}

View File

@ -26,7 +26,10 @@
"payPal": {
"production": true,
"businessId": "4ZDA7DLUUJGMN"
}
},
"onyx": {
"personaId": 7
}
},
"Logging": {
"IncludeScopes": false,

View File

@ -76,7 +76,8 @@
},
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api"
"baseUrl": "https://cloud.onyx.app/api",
"personaId": 7
}
}
}

View File

@ -2,6 +2,7 @@
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Auth.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
@ -43,11 +44,31 @@ public class AuthRequest : ITableObject<Guid>
public bool IsSpent()
{
return ResponseDate.HasValue || AuthenticationDate.HasValue || GetExpirationDate() < DateTime.UtcNow;
return ResponseDate.HasValue || AuthenticationDate.HasValue || IsExpired();
}
public bool IsExpired()
{
// TODO: PM-24252 - consider using TimeProvider for better mocking in tests
return GetExpirationDate() < DateTime.UtcNow;
}
// TODO: PM-24252 - this probably belongs in a service.
public bool IsValidForAuthentication(Guid userId,
string password)
{
return ResponseDate.HasValue // its been responded to
&& Approved == true // it was approved
&& !IsExpired() // it's not expired
&& Type == AuthRequestType.AuthenticateAndUnlock // its an authN request
&& !AuthenticationDate.HasValue // it was not already used for authN
&& UserId == userId // it belongs to the user
&& CoreHelpers.FixedTimeEquals(AccessCode, password); // the access code matches the password
}
public DateTime GetExpirationDate()
{
// TODO: PM-24252 - this should reference PasswordlessAuthSettings.UserRequestExpiration
return CreationDate.AddMinutes(15);
}
}

View File

@ -28,4 +28,10 @@ public class BillingCommandResult<T> : OneOfBase<T, BadRequest, Conflict, Unhand
public static implicit operator BillingCommandResult<T>(BadRequest badRequest) => new(badRequest);
public static implicit operator BillingCommandResult<T>(Conflict conflict) => new(conflict);
public static implicit operator BillingCommandResult<T>(Unhandled unhandled) => new(unhandled);
public Task TapAsync(Func<T, Task> f) => Match(
f,
_ => Task.CompletedTask,
_ => Task.CompletedTask,
_ => Task.CompletedTask);
}

View File

@ -532,6 +532,12 @@
<data name="AcceptInviteBeforeUsingSSO" xml:space="preserve">
<value>To accept your invite to {0}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.</value>
</data>
<data name="OrganizationUserAccessRevoked" xml:space="preserve">
<value>Your access to organization {0} has been revoked. Please contact your administrator for assistance.</value>
</data>
<data name="OrganizationUserUnknownStatus" xml:space="preserve">
<value>Your access to organization {0} is in an unknown state. Please contact your administrator for assistance.</value>
</data>
<data name="UserAlreadyExistsInviteProcess" xml:space="preserve">
<value>You were removed from the organization managing single sign-on for your account. Contact the organization administrator for help regaining access to your account.</value>
</data>

View File

@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Auth.Entities;
using Bit.Core.Entities;
using Duende.IdentityServer.Validation;
@ -41,4 +42,10 @@ public class CustomValidatorRequestContext
/// This will be null if the authentication request is successful.
/// </summary>
public Dictionary<string, object> CustomResponse { get; set; }
/// <summary>
/// A validated auth request
/// <see cref="AuthRequest.IsValidForAuthentication"/>
/// </summary>
public AuthRequest ValidatedAuthRequest { get; set; }
}

View File

@ -6,5 +6,6 @@ public enum DeviceValidationResultType : byte
InvalidUser = 1,
InvalidNewDeviceOtp = 2,
NewDeviceVerificationRequired = 3,
NoDeviceInformationProvided = 4
NoDeviceInformationProvided = 4,
AuthRequestFlowUnknownDevice = 5,
}

View File

@ -35,6 +35,7 @@ public abstract class BaseRequestValidator<T> where T : class
private readonly ILogger _logger;
private readonly GlobalSettings _globalSettings;
private readonly IUserRepository _userRepository;
private readonly IAuthRequestRepository _authRequestRepository;
protected ICurrentContext CurrentContext { get; }
protected IPolicyService PolicyService { get; }
@ -59,7 +60,9 @@ public abstract class BaseRequestValidator<T> where T : class
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository
)
{
_userManager = userManager;
_userService = userService;
@ -76,6 +79,7 @@ public abstract class BaseRequestValidator<T> where T : class
SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery;
_authRequestRepository = authRequestRepository;
}
protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@ -190,6 +194,14 @@ public abstract class BaseRequestValidator<T> where T : class
return;
}
// TODO: PM-24324 - This should be its own validator at some point.
// 6. Auth request handling
if (validatorContext.ValidatedAuthRequest != null)
{
validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow;
await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest);
}
await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken);
}
@ -404,8 +416,8 @@ public abstract class BaseRequestValidator<T> where T : class
/// <summary>
/// Builds the custom response that will be sent to the client upon successful authentication, which
/// includes the information needed for the client to initialize the user's account in state.
/// </summary>
/// <param name="user">The authenticated user.</param>
/// </summary>
/// <param name="user">The authenticated user.</param>
/// <param name="context">The current request context.</param>
/// <param name="device">The device used for authentication.</param>
/// <param name="sendRememberToken">Whether to send a 2FA remember token.</param>

View File

@ -45,7 +45,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IUpdateInstallationCommand updateInstallationCommand,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository)
: base(
userManager,
userService,
@ -61,7 +62,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery)
policyRequirementQuery,
authRequestRepository)
{
_userManager = userManager;
_updateInstallationCommand = updateInstallationCommand;

View File

@ -39,6 +39,8 @@ public class DeviceValidator(
private readonly ILogger<DeviceValidator> _logger = logger;
private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService;
private const string PasswordGrantType = "password";
public async Task<bool> ValidateRequestDeviceAsync(ValidatedTokenRequest request, CustomValidatorRequestContext context)
{
// Parse device from request and return early if no device information is provided
@ -68,10 +70,14 @@ public class DeviceValidator(
}
// We have established that the device is unknown at this point; begin new device verification
if (request.GrantType == "password" &&
request.Raw["AuthRequest"] == null &&
!context.TwoFactorRequired &&
!context.SsoRequired &&
// for standard password grant type requests
// Note: the auth request flow re-uses the resource owner password flow but new device verification
// is not required for auth requests
var rawAuthRequestId = request.Raw["AuthRequest"]?.ToLowerInvariant();
var isAuthRequest = !string.IsNullOrEmpty(rawAuthRequestId);
if (request.GrantType == PasswordGrantType &&
!isAuthRequest &&
context is { TwoFactorRequired: false, SsoRequired: false } &&
_globalSettings.EnableNewDeviceVerification)
{
var validationResult = await HandleNewDeviceVerificationAsync(context.User, request);
@ -87,6 +93,15 @@ public class DeviceValidator(
}
}
// Device still unknown, but if we are in an auth request flow, this is not valid
// as we only support auth request authN requests on known devices
if (request.GrantType == PasswordGrantType && isAuthRequest)
{
(context.ValidationErrorResult, context.CustomResponse) =
BuildDeviceErrorResult(DeviceValidationResultType.AuthRequestFlowUnknownDevice);
return false;
}
// At this point we have established either new device verification is not required or the NewDeviceOtp is valid,
// so we save the device to the database and proceed with authentication
requestDevice.UserId = context.User.Id;
@ -252,7 +267,7 @@ public class DeviceValidator(
var customResponse = new Dictionary<string, object>();
switch (errorType)
{
/*
/*
* The ErrorMessage is brittle and is used to control the flow in the clients. Do not change them without updating the client as well.
* There is a backwards compatibility issue as well: if you make a change on the clients then ensure that they are backwards
* compatible.
@ -273,6 +288,10 @@ public class DeviceValidator(
result.ErrorDescription = "No device information provided";
customResponse.Add("ErrorModel", new ErrorResponseModel("no device information provided"));
break;
case DeviceValidationResultType.AuthRequestFlowUnknownDevice:
result.ErrorDescription = "Auth requests are not supported on unknown devices";
customResponse.Add("ErrorModel", new ErrorResponseModel("auth request flow unsupported on unknown device"));
break;
}
return (result, customResponse);
}

View File

@ -11,7 +11,6 @@ using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Validation;
using Microsoft.AspNetCore.Identity;
@ -57,7 +56,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery)
policyRequirementQuery,
authRequestRepository)
{
_userManager = userManager;
_currentContext = currentContext;
@ -90,21 +90,33 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
return false;
}
var authRequestId = context.Request.Raw["AuthRequest"]?.ToString()?.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(authRequestId) && Guid.TryParse(authRequestId, out var authRequestGuid))
var authRequestId = context.Request.Raw["AuthRequest"]?.ToLowerInvariant();
if (!string.IsNullOrEmpty(authRequestId))
{
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestGuid);
if (authRequest != null)
// only allow valid guids
if (!Guid.TryParse(authRequestId, out var authRequestGuid))
{
var requestAge = DateTime.UtcNow - authRequest.CreationDate;
if (requestAge < TimeSpan.FromHours(1) &&
CoreHelpers.FixedTimeEquals(authRequest.AccessCode, context.Password))
{
authRequest.AuthenticationDate = DateTime.UtcNow;
await _authRequestRepository.ReplaceAsync(authRequest);
return true;
}
return false;
}
var authRequest = await _authRequestRepository.GetByIdAsync(authRequestGuid);
if (authRequest == null)
{
return false;
}
// Auth request is non-null so validate it
if (authRequest.IsValidForAuthentication(validatorContext.User.Id, context.Password))
{
// We save the validated auth request so that we can set it's authentication date
// later on only upon successful authentication.
// For example, 2FA requires a resubmission so we can't mark the auth request
// as authenticated here.
validatorContext.ValidatedAuthRequest = authRequest;
return true;
}
return false;
}

View File

@ -48,7 +48,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IPolicyRequirementQuery policyRequirementQuery)
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository)
: base(
userManager,
userService,
@ -64,7 +65,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery)
policyRequirementQuery,
authRequestRepository)
{
_assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;

View File

@ -16,16 +16,18 @@ namespace Bit.Identity.IntegrationTest.RequestValidation;
public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplicationFactory>
{
private const string DefaultPassword = "master_password_hash";
private const string DefaultUsername = "test@email.qa";
private const string DefaultDeviceIdentifier = "test_identifier";
private const string _defaultPassword = "master_password_hash";
private const string _defaultUsername = "test@email.qa";
private const string _defaultDeviceIdentifier = "test_identifier";
private const DeviceType _defaultDeviceType = DeviceType.FirefoxBrowser;
private const string _defaultDeviceName = "firefox";
[Fact]
public async Task ValidateAsync_Success()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
await EnsureUserCreatedAsync(localFactory);
await RegisterUserAsync(localFactory);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
@ -74,12 +76,12 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
{
// Arrange
var localFactory = new IdentityApplicationFactory();
await EnsureUserCreatedAsync(localFactory);
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
// Verify the User is not null to ensure the failure is due to bad password
Assert.NotNull(await userManager.FindByEmailAsync(DefaultUsername));
Assert.NotNull(await userManager.FindByEmailAsync(_defaultUsername));
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
@ -95,23 +97,142 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeLessThanOneHour_Success()
public async Task ValidateAsync_ValidateContextAsync_ValidAuthRequest_Success()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await EnsureUserCreatedAsync(localFactory);
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(DefaultUsername);
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Connect Request to User and set CreationDate
var authRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-30)
);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create valid auth request and tie it to the user
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", _defaultDeviceName },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
var token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
Assert.NotNull(token);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_ValidAuthRequest_UnknownDevice_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Create valid auth request and tie it to the user
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", _defaultDeviceName },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
var errorModel = AssertHelper.AssertJsonProperty(root, "ErrorModel", JsonValueKind.Object);
var errorMessage = AssertHelper.AssertJsonProperty(errorModel, "Message", JsonValueKind.String).GetString();
Assert.Equal("auth request flow unsupported on unknown device", errorMessage);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_Expired_AuthRequest_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-10); // 10 minutes ago
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-16); // expired after 15 minutes
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
@ -125,40 +246,51 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", DefaultDeviceIdentifier },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", DefaultUsername },
{ "password", DefaultPassword },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
var token = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString();
Assert.NotNull(token);
await AssertStandardError(context);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_NotNull_AgeGreaterThanOneHour_Failure()
public async Task ValidateAsync_ValidateContextAsync_Unapproved_AuthRequest_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await EnsureUserCreatedAsync(localFactory);
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(DefaultUsername);
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(
user.Id,
AuthRequestType.AuthenticateAndUnlock,
DateTime.UtcNow.AddMinutes(-61)
);
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
r.Approved = false; // NOT approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
@ -167,22 +299,137 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", DefaultDeviceIdentifier },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", DefaultUsername },
{ "password", DefaultPassword },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
/*
An improvement on the current failure flow would be to document which part of
the flow failed since all of the failures are basically the same.
This doesn't build confidence in the tests.
*/
await AssertStandardError(context);
}
public static IEnumerable<object[]> InvalidAuthRequestTypes()
{
// yield the two enum values that should fail
yield return [AuthRequestType.Unlock];
yield return [AuthRequestType.AdminApproval];
}
[Theory]
[MemberData(nameof(InvalidAuthRequestTypes))]
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_Invalid_Type_Failure(AuthRequestType invalidType)
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
r.Type = invalidType; // invalid type for authN
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
await AssertStandardError(context);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_AuthRequest_WrongUser_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User 1 exists
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure User 2 exists so we can satisfy auth request foreign key constraint
var user2Username = "user2@email.com";
var user2Password = "user2_password";
await RegisterUserAsync(localFactory, user2Username, user2Password);
var user2 = await userManager.FindByEmailAsync(user2Username);
Assert.NotNull(user2);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create valid auth request for user 2
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // 2 minutes ago
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // not expired
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user2.Id; // connect request to user2
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user2.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(_defaultDeviceType) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", _defaultDeviceName },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
@ -191,17 +438,180 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
}
private async Task EnsureUserCreatedAsync(IdentityApplicationFactory factory)
[Fact]
public async Task ValidateAsync_ValidateContextAsync_Unanswered_AuthRequest_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = null; // not answered
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // matches the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
await AssertStandardError(context);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_WrongPassword_AuthRequest_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // answered
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = null; // not used for authN yet
r.UserId = user.Id; // connect request to user
r.AccessCode = "WRONG_BAD_PASSWORD"; // does not match the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
await AssertStandardError(context);
}
[Fact]
public async Task ValidateAsync_ValidateContextAsync_Spent_AuthRequest_Failure()
{
// Arrange
var localFactory = new IdentityApplicationFactory();
// Ensure User
await RegisterUserAsync(localFactory);
var userManager = localFactory.GetService<UserManager<User>>();
var user = await userManager.FindByEmailAsync(_defaultUsername);
Assert.NotNull(user);
// Ensure device is known b/c auth requests aren't allowed for unknown devices.
await AddKnownDevice(localFactory, user.Id);
// Create AuthRequest
var authRequest = CreateAuthRequest(r =>
{
r.ResponseDate = DateTime.UtcNow.AddMinutes(-2); // answered
r.Approved = true; // approved
r.CreationDate = DateTime.UtcNow.AddMinutes(-5); // still valid
r.Type = AuthRequestType.AuthenticateAndUnlock; // authN request
r.AuthenticationDate = DateTime.UtcNow.AddMinutes(-2); // spent request - already has been used for authN
r.UserId = user.Id; // connect request to user
r.AccessCode = _defaultPassword; // does not match the password
});
var authRequestRepository = localFactory.GetService<IAuthRequestRepository>();
await authRequestRepository.CreateAsync(authRequest);
var expectedAuthRequest = await authRequestRepository.GetManyByUserIdAsync(user.Id);
Assert.NotEmpty(expectedAuthRequest);
// Act
var context = await localFactory.Server.PostAsync("/connect/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
{ "AuthRequest", authRequest.Id.ToString().ToLowerInvariant() }
}));
// Assert
await AssertStandardError(context);
}
private async Task RegisterUserAsync(
IdentityApplicationFactory factory,
string username = _defaultUsername,
string password = _defaultPassword
)
{
// Register user
await factory.RegisterNewIdentityFactoryUserAsync(
new RegisterFinishRequestModel
{
Email = DefaultUsername,
MasterPasswordHash = DefaultPassword,
Email = username,
MasterPasswordHash = password,
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = AuthConstants.PBKDF2_ITERATIONS.Default,
UserAsymmetricKeys = new KeysRequestModel()
UserAsymmetricKeys = new KeysRequestModel
{
PublicKey = "public_key",
EncryptedPrivateKey = "private_key"
@ -218,11 +628,11 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "deviceType", DeviceTypeAsString(DeviceType.FirefoxBrowser) },
{ "deviceIdentifier", deviceId ?? DefaultDeviceIdentifier },
{ "deviceIdentifier", deviceId ?? _defaultDeviceIdentifier },
{ "deviceName", "firefox" },
{ "grant_type", "password" },
{ "username", username ?? DefaultUsername },
{ "password", password ?? DefaultPassword },
{ "username", username ?? _defaultUsername },
{ "password", password ?? _defaultPassword },
});
}
@ -233,8 +643,8 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
{ "scope", "api offline_access" },
{ "client_id", "web" },
{ "grant_type", "password" },
{ "username", DefaultUsername },
{ "password", DefaultPassword },
{ "username", _defaultUsername },
{ "password", _defaultPassword },
});
}
@ -243,24 +653,53 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture<IdentityApplica
return ((int)deviceType).ToString();
}
private static AuthRequest CreateAuthRequest(
Guid userId,
AuthRequestType authRequestType,
DateTime creationDate,
bool? approved = null,
DateTime? responseDate = null)
private AuthRequest CreateAuthRequest(Action<AuthRequest>? customize = null)
{
return new AuthRequest
var req = new AuthRequest
{
UserId = userId,
Type = authRequestType,
Approved = approved,
RequestDeviceIdentifier = DefaultDeviceIdentifier,
// required fields with defaults
UserId = Guid.NewGuid(),
Type = AuthRequestType.AuthenticateAndUnlock,
RequestDeviceIdentifier = _defaultDeviceIdentifier,
RequestIpAddress = "1.1.1.1",
AccessCode = DefaultPassword,
AccessCode = _defaultPassword,
PublicKey = "test_public_key",
CreationDate = creationDate,
ResponseDate = responseDate,
CreationDate = DateTime.UtcNow,
};
// let the caller tweak whatever they need
customize?.Invoke(req);
return req;
}
private async Task AddKnownDevice(IdentityApplicationFactory factory, Guid userId)
{
var userDevice = new Device
{
Identifier = _defaultDeviceIdentifier,
Type = _defaultDeviceType,
Name = _defaultDeviceName,
UserId = userId,
};
var deviceRepository = factory.GetService<IDeviceRepository>();
await deviceRepository.CreateAsync(userDevice);
}
private async Task AssertStandardError(HttpContext context)
{
/*
An improvement on the current failure flow would be to document which part of
the flow failed since all of the failures are basically the same.
This doesn't build confidence in the tests.
*/
var body = await AssertHelper.AssertResponseTypeIs<JsonDocument>(context);
var root = body.RootElement;
var errorModel = AssertHelper.AssertJsonProperty(root, "ErrorModel", JsonValueKind.Object);
var errorMessage = AssertHelper.AssertJsonProperty(errorModel, "Message", JsonValueKind.String).GetString();
Assert.Equal("Username or password is incorrect. Try again.", errorMessage);
}
}

View File

@ -3,6 +3,8 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
@ -42,6 +44,7 @@ public class BaseRequestValidatorTests
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IAuthRequestRepository _authRequestRepository;
private readonly BaseRequestValidatorTestWrapper _sut;
@ -62,6 +65,7 @@ public class BaseRequestValidatorTests
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_authRequestRepository = Substitute.For<IAuthRequestRepository>();
_sut = new BaseRequestValidatorTestWrapper(
_userManager,
@ -78,7 +82,8 @@ public class BaseRequestValidatorTests
_featureService,
_ssoConfigRepository,
_userDecryptionOptionsBuilder,
_policyRequirementQuery);
_policyRequirementQuery,
_authRequestRepository);
}
/* Logic path
@ -175,6 +180,99 @@ public class BaseRequestValidatorTests
Assert.False(context.GrantResult.IsError);
}
[Theory, BitAutoData]
public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
var authRequest = new AuthRequest
{
Type = AuthRequestType.AuthenticateAndUnlock,
RequestDeviceIdentifier = "",
RequestIpAddress = "1.1.1.1",
AccessCode = "password",
PublicKey = "test_public_key",
CreationDate = DateTime.UtcNow.AddMinutes(-5),
ResponseDate = DateTime.UtcNow.AddMinutes(-2),
Approved = true,
AuthenticationDate = null, // unused
UserId = requestContext.User.Id,
};
requestContext.ValidatedAuthRequest = authRequest;
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be false
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
// 4 -> set up device validator to pass
_deviceValidator.ValidateRequestDeviceAsync(Arg.Any<ValidatedTokenRequest>(), Arg.Any<CustomValidatorRequestContext>())
.Returns(Task.FromResult(true));
// 5 -> not legacy user
_userService.IsLegacyUser(Arg.Any<string>())
.Returns(false);
// Act
await _sut.ValidateAsync(context);
// Assert
Assert.False(context.GrantResult.IsError);
// Check that the auth request was consumed
await _authRequestRepository.Received(1).ReplaceAsync(Arg.Is<AuthRequest>(ar =>
ar.AuthenticationDate.HasValue));
}
[Theory, BitAutoData]
public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired(
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
var context = CreateContext(tokenRequest, requestContext, grantResult);
// 1 -> to pass
_sut.isValid = true;
var authRequest = new AuthRequest
{
Type = AuthRequestType.AuthenticateAndUnlock,
RequestDeviceIdentifier = "",
RequestIpAddress = "1.1.1.1",
AccessCode = "password",
PublicKey = "test_public_key",
CreationDate = DateTime.UtcNow.AddMinutes(-5),
ResponseDate = DateTime.UtcNow.AddMinutes(-2),
Approved = true,
AuthenticationDate = null, // unused
UserId = requestContext.User.Id,
};
requestContext.ValidatedAuthRequest = authRequest;
// 2 -> will result to false with no extra configuration
// 3 -> set two factor to be required
_twoFactorAuthenticationValidator
.RequiresTwoFactorAsync(Arg.Any<User>(), tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(true, null)));
// Act
await _sut.ValidateAsync(context);
// Assert we errored for 2fa requirement
Assert.True(context.GrantResult.IsError);
// Assert that the auth request was NOT consumed
await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any<AuthRequest>());
}
// Test grantTypes that require SSO when a user is in an organization that requires it
[Theory]
[BitAutoData("password")]

View File

@ -324,14 +324,26 @@ public class DeviceValidatorTests
Assert.True(result);
}
[Theory, BitAutoData]
public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue(
[Theory]
[BitAutoData(false, false)]
[BitAutoData(true, false)]
[BitAutoData(true, true)]
[BitAutoData(true, false)]
public async void ValidateRequestDeviceAsync_IsAuthRequest_UnknownDevice_Errors(
bool twoFactoRequired, bool ssoRequired,
CustomValidatorRequestContext context,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request)
{
// Arrange
context.KnownDevice = false;
ArrangeForHandleNewDeviceVerificationTest(context, request);
request.GrantType = "password";
context.TwoFactorRequired = twoFactoRequired;
context.SsoRequired = ssoRequired;
if (context.User != null)
{
context.User.CreationDate = DateTime.UtcNow - TimeSpan.FromDays(365);
}
AddValidDeviceToRequest(request);
_deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id)
.Returns(null as Device);
@ -342,8 +354,11 @@ public class DeviceValidatorTests
var result = await _sut.ValidateRequestDeviceAsync(request, context);
// Assert
await _deviceService.Received(1).SaveAsync(context.Device);
Assert.True(result);
Assert.False(result);
Assert.NotNull(context.CustomResponse["ErrorModel"]);
var expectedErrorMessage = "auth request flow unsupported on unknown device";
var actualResponse = (ErrorResponseModel)context.CustomResponse["ErrorModel"];
Assert.Equal(expectedErrorMessage, actualResponse.Message);
}
[Theory, BitAutoData]

View File

@ -62,7 +62,8 @@ IBaseRequestValidatorTestWrapper
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery) :
IPolicyRequirementQuery policyRequirementQuery,
IAuthRequestRepository authRequestRepository) :
base(
userManager,
userService,
@ -78,7 +79,8 @@ IBaseRequestValidatorTestWrapper
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder,
policyRequirementQuery)
policyRequirementQuery,
authRequestRepository)
{
}