diff --git a/src/Core/Auth/Entities/AuthRequest.cs b/src/Core/Auth/Entities/AuthRequest.cs index af429adca2..2117c575c0 100644 --- a/src/Core/Auth/Entities/AuthRequest.cs +++ b/src/Core/Auth/Entities/AuthRequest.cs @@ -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 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 // it’s been responded to + && Approved == true // it was approved + && !IsExpired() // it's not expired + && Type == AuthRequestType.AuthenticateAndUnlock // it’s 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); } } diff --git a/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs index 45c901e306..f8d04eccfb 100644 --- a/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs +++ b/src/Identity/IdentityServer/Enums/DeviceValidationResultType.cs @@ -6,5 +6,6 @@ public enum DeviceValidationResultType : byte InvalidUser = 1, InvalidNewDeviceOtp = 2, NewDeviceVerificationRequired = 3, - NoDeviceInformationProvided = 4 + NoDeviceInformationProvided = 4, + AuthRequestFlowUnknownDevice = 5, } diff --git a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs index 44dc89d259..42504ae813 100644 --- a/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/DeviceValidator.cs @@ -39,6 +39,8 @@ public class DeviceValidator( private readonly ILogger _logger = logger; private readonly ITwoFactorEmailService _twoFactorEmailService = twoFactorEmailService; + private const string PasswordGrantType = "password"; + public async Task 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,16 @@ 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 is { TwoFactorRequired: false, SsoRequired: false }) + { + (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 +268,7 @@ public class DeviceValidator( var customResponse = new Dictionary(); 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 +289,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); } diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index fe32d3e1b8..c5d2db38f4 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -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; @@ -90,21 +89,30 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator { - 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>(); // 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>(); - 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(); + 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 + { + { "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(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>(); + 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(); + 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 + { + { "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(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>(); + + 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(); await authRequestRepository.CreateAsync(authRequest); @@ -125,40 +246,51 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture(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>(); - 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(); + 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 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>(); + + 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(); + 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 + { + { "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>(); + 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(); + 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 + { + { "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(context); var root = body.RootElement; @@ -191,17 +438,180 @@ public class ResourceOwnerPasswordValidatorTests : IClassFixture>(); + + 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(); + 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 + { + { "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>(); + + 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(); + 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 + { + { "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>(); + + 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(); + 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 + { + { "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? 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(); + 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(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); } } diff --git a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs index 9058d26cf1..681b8c3a2f 100644 --- a/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/DeviceValidatorTests.cs @@ -325,12 +325,11 @@ public class DeviceValidatorTests } [Theory, BitAutoData] - public async void ValidateRequestDeviceAsync_IsAuthRequest_SavesDevice_ReturnsTrue( + public async void ValidateRequestDeviceAsync_IsAuthRequest_UnknownDevice_Errors( CustomValidatorRequestContext context, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest request) { // Arrange - context.KnownDevice = false; ArrangeForHandleNewDeviceVerificationTest(context, request); AddValidDeviceToRequest(request); _deviceRepository.GetByIdentifierAsync(context.Device.Identifier, context.User.Id) @@ -342,8 +341,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]