diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs index e16c8ad69553..feb83f624e48 100644 --- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs +++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs @@ -57,4 +57,14 @@ public class CustomValidatorRequestContext /// Whether the user has requested a Remember Me token for their current device. /// public bool RememberMeRequested { get; set; } = false; + /// + /// Whether the Remember Me token presented on this request was in the previous format and should + /// be replaced with one in the current format on this response. + /// + /// + /// Distinct from on purpose. It is set only when a + /// previous-format token was accepted, never merely because the request used the Remember + /// provider — which is what keeps a remember-login from extending its own lifetime. + /// + public bool RememberUpgradeRequired { get; set; } = false; } diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 6fafe1453c01..8ce108913b5a 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -12,6 +12,7 @@ using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -22,7 +23,6 @@ using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Duende.IdentityServer.Validation; using Microsoft.AspNetCore.Identity; @@ -42,6 +42,7 @@ public abstract class BaseRequestValidator where T : class private readonly IMailService _mailService; private readonly IClientVersionValidator _clientVersionValidator; protected readonly IUpdateDeviceLastActivityCommand _updateDeviceLastActivityCommand; + private readonly IIssueTwoFactorRememberTokenCommand _issueTwoFactorRememberTokenCommand; protected ICurrentContext CurrentContext { get; } protected Bitwarden.Server.Sdk.Features.IFeatureService _featureService { get; } @@ -70,7 +71,8 @@ public BaseRequestValidator( IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery, IClientVersionValidator clientVersionValidator, - IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand + IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand, + IIssueTwoFactorRememberTokenCommand issueTwoFactorRememberTokenCommand ) { _userManager = userManager; @@ -92,6 +94,7 @@ IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand _accountKeysQuery = userAccountKeysQuery; _clientVersionValidator = clientVersionValidator; _updateDeviceLastActivityCommand = updateDeviceLastActivityCommand; + _issueTwoFactorRememberTokenCommand = issueTwoFactorRememberTokenCommand; } // NOTE: Feature flags with progressive rollout (device-keyed or user-keyed) cannot be @@ -113,8 +116,12 @@ protected async Task ValidateAsync(T context, ValidatedTokenRequest request, return; } + // A remember token is issued either because the user asked for one, or because they + // presented one in the previous format and it is being replaced. The second condition is + // set only on a successful previous-format validation — never merely because the request + // used the Remember provider, which would let an active user's remember-me renew forever. await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device, - validatorContext.RememberMeRequested); + validatorContext.RememberMeRequested || validatorContext.RememberUpgradeRequired); } protected async Task FailAuthForLegacyUserAsync(User user, T context) @@ -321,10 +328,12 @@ private async Task ValidateTwoFactorAsync(T context, ValidatedTokenRequest return false; } - var twoFactorTokenValid = + var twoFactorResult = await _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(validatorContext.User, twoFactorOrganization, twoFactorProviderType, twoFactorToken, request.Raw["DeviceIdentifier"]); + var twoFactorTokenValid = twoFactorResult.Succeeded; + validatorContext.RememberUpgradeRequired = twoFactorResult.LegacyRememberUpgradeRequired; // 3b. Response for 2FA required but request is not valid or remember token expired state. if (!twoFactorTokenValid) @@ -654,9 +663,9 @@ private async Task> BuildCustomResponse(User user, T if (sendRememberToken) { - var token = await _userManager.GenerateTwoFactorTokenAsync(user, - CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); - customResponse.Add("TwoFactorToken", token); + // The device is non-null here because this runs only after DeviceValidator succeeded + var rememberToken = await _issueTwoFactorRememberTokenCommand.IssueAsync(user, device); + customResponse.Add("TwoFactorToken", rememberToken); } return customResponse; diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index 5f6dd5aa9622..d4446c91590d 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.KeyManagement.Queries.Interfaces; @@ -47,7 +48,8 @@ public CustomTokenRequestValidator( IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery, IClientVersionValidator clientVersionValidator, - IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand) + IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand, + IIssueTwoFactorRememberTokenCommand issueTwoFactorRememberTokenCommand) : base( userManager, userService, @@ -67,7 +69,8 @@ public CustomTokenRequestValidator( mailService, userAccountKeysQuery, clientVersionValidator, - updateDeviceLastActivityCommand) + updateDeviceLastActivityCommand, + issueTwoFactorRememberTokenCommand) { _userManager = userManager; _updateInstallationCommand = updateInstallationCommand; diff --git a/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs index 29dda3570f2b..008686bd96e2 100644 --- a/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ITwoFactorAuthenticationValidator.cs @@ -34,9 +34,20 @@ public interface ITwoFactorAuthenticationValidator /// Two Factor Provider to use to verify the token /// secret passed from the user and consumed by the two-factor provider's verify method /// - /// identifier of the device making the request; an email code only verifies for the device it was issued to + /// The client-generated device identifier from the raw request. An email code only verifies for the + /// device it was issued to, and a remember token is bound to its device. /// - /// boolean - Task VerifyTwoFactorAsync(User user, Organization organization, TwoFactorProviderType twoFactorProviderType, - string token, string deviceIdentifier); + Task VerifyTwoFactorAsync( + User user, + Organization organization, + TwoFactorProviderType twoFactorProviderType, + string token, + string deviceIdentifier); } + +/// Whether the presented second factor was accepted. +/// +/// Set when a remember token in the pre-TwoFactorRememberTokenable format was accepted, so +/// the caller knows to issue a replacement in the current format on this response. +/// +public record TwoFactorVerificationResult(bool Succeeded, bool LegacyRememberUpgradeRequired = false); diff --git a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs index 03055079f424..d677bdf65d90 100644 --- a/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/ResourceOwnerPasswordValidator.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.KeyManagement.Queries.Interfaces; @@ -44,7 +45,8 @@ public ResourceOwnerPasswordValidator( IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery, IClientVersionValidator clientVersionValidator, - IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand) + IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand, + IIssueTwoFactorRememberTokenCommand issueTwoFactorRememberTokenCommand) : base( userManager, userService, @@ -64,7 +66,8 @@ public ResourceOwnerPasswordValidator( mailService, userAccountKeysQuery, clientVersionValidator, - updateDeviceLastActivityCommand) + updateDeviceLastActivityCommand, + issueTwoFactorRememberTokenCommand) { _userManager = userManager; _currentContext = currentContext; diff --git a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs index 0ddbd1a7c18e..039446118e61 100644 --- a/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/TwoFactorAuthenticationValidator.cs @@ -9,6 +9,7 @@ using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; @@ -32,8 +33,12 @@ public class TwoFactorAuthenticationValidator( IDataProtectorTokenFactory ssoEmail2faSessionTokeFactory, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ICurrentContext currentContext, - ITwoFactorEmailService twoFactorEmailService) : ITwoFactorAuthenticationValidator + ITwoFactorEmailService twoFactorEmailService, + IValidateTwoFactorRememberTokenQuery validateTwoFactorRememberTokenQuery) : ITwoFactorAuthenticationValidator { + private readonly IValidateTwoFactorRememberTokenQuery _validateTwoFactorRememberTokenQuery = + validateTwoFactorRememberTokenQuery; + private readonly IUserService _userService = userService; private readonly UserManager _userManager = userManager; private readonly IOrganizationDuoUniversalTokenProvider _organizationDuoUniversalTokenProvider = organizationDuoWebTokenProvider; @@ -120,7 +125,7 @@ public async Task> BuildTwoFactorResultAsync(User use return twoFactorResultDict; } - public async Task VerifyTwoFactorAsync( + public async Task VerifyTwoFactorAsync( User user, Organization organization, TwoFactorProviderType type, @@ -131,14 +136,15 @@ public async Task VerifyTwoFactorAsync( { if (organization.TwoFactorProviderIsEnabled(type)) { - return await _organizationDuoUniversalTokenProvider.ValidateAsync(token, organization, user); + return new TwoFactorVerificationResult( + await _organizationDuoUniversalTokenProvider.ValidateAsync(token, organization, user)); } - return false; + return new TwoFactorVerificationResult(false); } if (type is TwoFactorProviderType.RecoveryCode) { - return await _userService.RecoverTwoFactorAsync(user, token); + return new TwoFactorVerificationResult(await _userService.RecoverTwoFactorAsync(user, token)); } // These cases we want to always return false, U2f is deprecated and OrganizationDuo @@ -147,31 +153,51 @@ public async Task VerifyTwoFactorAsync( // provider flow. See IOrganizationDuoUniversalTokenProvider.cs if (type is TwoFactorProviderType.U2f or TwoFactorProviderType.OrganizationDuo) { - return false; + return new TwoFactorVerificationResult(false); + } + + // Remember is not a provider a user configures, so user.GetTwoFactorProvider(Remember) is always + // null and the lookup below would reject it. It is validated on its own terms here instead. + if (type is TwoFactorProviderType.Remember) + { + // TODO: PM-44171 - Remove the legacy token path. PM-40673 introduced a new remember token format and stopped + // issuing legacy format tokens. Legacy tokens have a 30 day expiration, so we must wait until more than 30 days + // have passed after the release of PM-40673 before we remove this legacy path. + var isLegacyFormat = token is null || !token.StartsWith(TwoFactorRememberTokenable.ClearTextPrefix, StringComparison.Ordinal); + + if (isLegacyFormat) + { + var legacyTokenValid = await _userManager.VerifyTwoFactorTokenAsync( + user, CoreHelpers.CustomProviderName(type), token); + + // Accepting a legacy format token earns a replacement in the new format on this same response, + // so the device migrates over on this login rather than waiting out the legacy token's lifetime. + return new TwoFactorVerificationResult(legacyTokenValid, legacyTokenValid); + } + + return new TwoFactorVerificationResult( + await _validateTwoFactorRememberTokenQuery.ValidateAsync(user, deviceIdentifier, token)); } // Now we are concerning the rest of the Two Factor Provider Types // The intent of this check is to make sure that the user is using a 2FA provider that // is enabled and allowed by their premium status. - // The exception for Remember is because it is a "special" 2FA type that isn't ever explicitly - // enabled by a user, so we can't check the user's 2FA providers to see if they're - // enabled. We just have to check if the token is valid. - if (type != TwoFactorProviderType.Remember && - user.GetTwoFactorProvider(type) == null) + if (user.GetTwoFactorProvider(type) == null) { - return false; + return new TwoFactorVerificationResult(false); } // Email codes are bound to the device they were issued to, so they are verified with that device. if (type == TwoFactorProviderType.Email) { - return await _twoFactorEmailService.VerifyTwoFactorLoginTokenAsync(user, deviceIdentifier, token); + return new TwoFactorVerificationResult( + await _twoFactorEmailService.VerifyTwoFactorLoginTokenAsync(user, deviceIdentifier, token)); } // Finally, verify the token based on the provider type. - return await _userManager.VerifyTwoFactorTokenAsync( - user, CoreHelpers.CustomProviderName(type), token); + return new TwoFactorVerificationResult( + await _userManager.VerifyTwoFactorTokenAsync(user, CoreHelpers.CustomProviderName(type), token)); } private async Task>> GetEnabledTwoFactorProvidersAsync( diff --git a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs index 8c03677d2dd3..2aac9a4d809d 100644 --- a/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/WebAuthnGrantValidator.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Auth.UserFeatures.WebAuthnLogin; using Bit.Core.Context; using Bit.Core.Entities; @@ -50,7 +51,8 @@ public WebAuthnGrantValidator( IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery, IClientVersionValidator clientVersionValidator, - IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand) + IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand, + IIssueTwoFactorRememberTokenCommand issueTwoFactorRememberTokenCommand) : base( userManager, userService, @@ -70,7 +72,8 @@ public WebAuthnGrantValidator( mailService, userAccountKeysQuery, clientVersionValidator, - updateDeviceLastActivityCommand) + updateDeviceLastActivityCommand, + issueTwoFactorRememberTokenCommand) { _assertionOptionsDataProtector = assertionOptionsDataProtector; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; diff --git a/test/Identity.IntegrationTest/Endpoints/RememberedDeviceLoginTests.cs b/test/Identity.IntegrationTest/Endpoints/RememberedDeviceLoginTests.cs new file mode 100644 index 000000000000..f175298b3fbe --- /dev/null +++ b/test/Identity.IntegrationTest/Endpoints/RememberedDeviceLoginTests.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using Bit.Core.Auth.Enums; +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; +using Bit.Core.KeyManagement.Kdf; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.IntegrationTestCommon.Factories; +using Bit.Test.Common.Helpers; +using Xunit; + +namespace Bit.Identity.IntegrationTest.Endpoints; + +/// +/// End-to-end coverage for the "remember this device" flow: a token issued by one login is honored +/// by the next, and is refused when it should not be. +/// +public class RememberedDeviceLoginTests +{ + private const string _testEmail = "test+rememberdevice@email.com"; + private const string _testPassword = "master_password_hash"; + private const string _userEmailTwoFactor = + """{"1": { "Enabled": true, "MetaData": { "Email": "test+rememberdevice@email.com"}}}"""; + + /// + /// The numeric value of . + /// + private const string _rememberProvider = "5"; + + private const string _emailProvider = "1"; + + private static async Task<(IdentityApplicationFactory Factory, User User)> CreateFactoryWithTwoFactorUserAsync() + { + var factory = new IdentityApplicationFactory(); + + var user = await factory.RegisterNewIdentityFactoryUserAsync( + new RegisterFinishRequestModel + { + Email = _testEmail, + MasterPasswordHash = _testPassword, + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = KdfConstants.PBKDF2_ITERATIONS.Default, + UserAsymmetricKeys = new KeysRequestModel + { + PublicKey = Bit.Test.Common.Constants.TestEncryptionConstants.PublicKey, + EncryptedPrivateKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring + }, + UserSymmetricKey = Bit.Test.Common.Constants.TestEncryptionConstants.AES256_CBC_HMAC_Encstring, + }); + + var userRepository = factory.Services.GetRequiredService(); + var userService = factory.GetService(); + user.TwoFactorProviders = _userEmailTwoFactor; + await userRepository.ReplaceAsync(user); + await userService.GetUserByIdAsync(user.Id); + + return (factory, user); + } + + /// + /// Issues a real email two-factor login code bound to the device and returns it, as the factory's mail + /// substitute captured it. + /// + private static async Task EmailLoginCodeAsync( + IdentityApplicationFactory factory, string email, string deviceIdentifier) + { + var user = await factory.GetService().GetByEmailAsync(email); + await factory.GetService().SendTwoFactorLoginEmailAsync(user, deviceIdentifier); + return factory.TwoFactorEmailCodes[email]; + } + + /// + /// A two-factor login that asks to be remembered gets a token back. + /// + [Fact] + public async Task RememberRequested_ResponseCarriesRememberToken() + { + var (factory, _) = await CreateFactoryWithTwoFactorUserAsync(); + + var emailCode = await EmailLoginCodeAsync( + factory, _testEmail, IdentityApplicationFactory.DefaultDeviceIdentifier); + var (accessToken, rememberToken) = await factory.TokensFromPasswordWithTwoFactorAsync( + _testEmail, _testPassword, twoFactorProviderType: _emailProvider, twoFactorToken: emailCode); + + Assert.NotNull(accessToken); + Assert.False(string.IsNullOrEmpty(rememberToken)); + } + + /// + /// The token is honored on a later login, with no second factor supplied. + /// + [Fact] + public async Task RememberToken_ReplayedOnNextLogin_SkipsTwoFactorChallenge() + { + var (factory, _) = await CreateFactoryWithTwoFactorUserAsync(); + + var emailCode = await EmailLoginCodeAsync( + factory, _testEmail, IdentityApplicationFactory.DefaultDeviceIdentifier); + var (_, rememberToken) = await factory.TokensFromPasswordWithTwoFactorAsync( + _testEmail, _testPassword, twoFactorProviderType: _emailProvider, twoFactorToken: emailCode); + Assert.False(string.IsNullOrEmpty(rememberToken)); + + // Clients send remember=0 when re-authenticating with a stored token. + var context = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + twoFactorProviderType: _rememberProvider, + twoFactorToken: rememberToken!, + twoFactorRemember: "0"); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + var accessToken = AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String).GetString(); + Assert.False(string.IsNullOrEmpty(accessToken)); + } + + /// + /// A login that presents a remember token must not be issued another one, or an active user's + /// remember-me would renew forever. Replayed with and without asking to be remembered again, since + /// a client can send TwoFactorRemember=1 regardless. + /// + [Theory] + [InlineData("0")] + [InlineData("1")] + public async Task RememberToken_ReplayedOnNextLogin_DoesNotIssueAnotherToken(string twoFactorRemember) + { + var (factory, _) = await CreateFactoryWithTwoFactorUserAsync(); + + var emailCode = await EmailLoginCodeAsync( + factory, _testEmail, IdentityApplicationFactory.DefaultDeviceIdentifier); + var (_, rememberToken) = await factory.TokensFromPasswordWithTwoFactorAsync( + _testEmail, _testPassword, twoFactorProviderType: _emailProvider, twoFactorToken: emailCode); + Assert.False(string.IsNullOrEmpty(rememberToken)); + + var context = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + twoFactorProviderType: _rememberProvider, + twoFactorToken: rememberToken!, + twoFactorRemember: twoFactorRemember); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String); + Assert.False(root.TryGetProperty("TwoFactorToken", out _)); + } + + [Fact] + public async Task LegacyRememberToken_IsStillAccepted() + { + var (factory, user) = await CreateFactoryWithTwoFactorUserAsync(); + var legacyToken = await factory.GenerateLegacyRememberTokenAsync(user); + + var context = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + twoFactorProviderType: _rememberProvider, + twoFactorToken: legacyToken, + twoFactorRemember: "0"); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + AssertHelper.AssertJsonProperty(root, "access_token", JsonValueKind.String); + } + + /// + /// Accepting a legacy token also replaces it, so each device moves to the current format on + /// its next login rather than waiting for its token to expire. + /// + [Fact] + public async Task LegacyRememberToken_IsUpgradedToCurrentFormat() + { + var (factory, user) = await CreateFactoryWithTwoFactorUserAsync(); + var legacyToken = await factory.GenerateLegacyRememberTokenAsync(user); + + var context = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + twoFactorProviderType: _rememberProvider, + twoFactorToken: legacyToken, + twoFactorRemember: "0"); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + var upgraded = AssertHelper.AssertJsonProperty(root, "TwoFactorToken", JsonValueKind.String).GetString(); + Assert.StartsWith(TwoFactorRememberTokenable.ClearTextPrefix, upgraded); + + // And the replacement works on the login after that. + var replayContext = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + twoFactorProviderType: _rememberProvider, + twoFactorToken: upgraded!, + twoFactorRemember: "0"); + + using var replayBody = await AssertHelper.AssertResponseTypeIs(replayContext); + AssertHelper.AssertJsonProperty(replayBody.RootElement, "access_token", JsonValueKind.String); + } + + /// + /// The token is bound to the device it was issued to, so presenting it from another device + /// falls back to a two-factor challenge. + /// + [Fact] + public async Task RememberToken_PresentedFromDifferentDevice_IsRefused() + { + var (factory, _) = await CreateFactoryWithTwoFactorUserAsync(); + + var emailCode = await EmailLoginCodeAsync( + factory, _testEmail, IdentityApplicationFactory.DefaultDeviceIdentifier); + var (_, rememberToken) = await factory.TokensFromPasswordWithTwoFactorAsync( + _testEmail, _testPassword, twoFactorProviderType: _emailProvider, twoFactorToken: emailCode); + Assert.False(string.IsNullOrEmpty(rememberToken)); + + var context = await factory.ContextFromPasswordWithTwoFactorAsync( + _testEmail, + _testPassword, + deviceIdentifier: Guid.NewGuid().ToString(), + twoFactorProviderType: _rememberProvider, + twoFactorToken: rememberToken!, + twoFactorRemember: "0"); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + // A refused remember token falls back to a challenge rather than a generic auth failure: + // the password was fine, the device just is not trusted. + Assert.False(root.TryGetProperty("access_token", out _)); + var error = AssertHelper.AssertJsonProperty(root, "error_description", JsonValueKind.String).GetString(); + Assert.Equal("Two factor required.", error); + AssertHelper.AssertJsonProperty(root, "TwoFactorProviders2", JsonValueKind.Object); + } +} diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index f9bff31a9736..f829d82c7f19 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -8,6 +8,7 @@ using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -60,6 +61,7 @@ public class BaseRequestValidatorTests private readonly IUserAccountKeysQuery _userAccountKeysQuery; private readonly IClientVersionValidator _clientVersionValidator; private readonly IUpdateDeviceLastActivityCommand _updateDeviceLastActivityCommand; + private readonly IIssueTwoFactorRememberTokenCommand _issueTwoFactorRememberTokenCommand; private readonly BaseRequestValidatorTestWrapper _sut; @@ -84,6 +86,7 @@ public BaseRequestValidatorTests() _userAccountKeysQuery = Substitute.For(); _clientVersionValidator = Substitute.For(); _updateDeviceLastActivityCommand = Substitute.For(); + _issueTwoFactorRememberTokenCommand = Substitute.For(); _sut = new BaseRequestValidatorTestWrapper( _userManager, @@ -104,7 +107,8 @@ public BaseRequestValidatorTests() _mailService, _userAccountKeysQuery, _clientVersionValidator, - _updateDeviceLastActivityCommand); + _updateDeviceLastActivityCommand, + _issueTwoFactorRememberTokenCommand); // Default client version validator behavior: allow to pass unless a test overrides. _clientVersionValidator @@ -397,7 +401,7 @@ public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorE // 4 -> set up 2FA verification to fail _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Email, "invalid_token", "requesting-device") - .Returns(Task.FromResult(false)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(false))); // 5 -> set up SSO required verification to succeed _ssoRequestValidator.ValidateAsync(requestContext.User, tokenRequest, requestContext) @@ -450,7 +454,7 @@ public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFaile // 5 -> set up remember token verification to fail _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.Remember, "expired_remember_token", Arg.Any()) - .Returns(Task.FromResult(false)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(false))); // 6 -> set up dummy BuildTwoFactorResultAsync var twoFactorResultDict = new Dictionary @@ -987,7 +991,7 @@ public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescrip // 5. Recovery code is valid (UserService.RecoverTwoFactorAsync will be called internally) _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-12345", Arg.Any()) - .Returns(Task.FromResult(true)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(true))); // Act await _sut.ValidateAsync(context); @@ -1051,7 +1055,7 @@ public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA // 5. Recovery code is INVALID _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code", Arg.Any()) - .Returns(Task.FromResult(false)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(false))); // Act await _sut.ValidateAsync(context); @@ -1130,7 +1134,7 @@ public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin( // 5. Recovery code is valid _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-67890", Arg.Any()) - .Returns(Task.FromResult(true)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(true))); // 6. Device validation passes _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -1275,7 +1279,7 @@ public async Task ValidateAsync_RecoveryWithSso_CorrectValidatorMessage( _twoFactorAuthenticationValidator .VerifyTwoFactorAsync(requestContext.User, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code", Arg.Any()) - .Returns(Task.FromResult(true)); + .Returns(Task.FromResult(new TwoFactorVerificationResult(true))); // SsoRequestValidator handles the recovery + SSO scenario requestContext.TwoFactorRecoveryRequested = true; diff --git a/test/Identity.Test/IdentityServer/CustomTokenRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/CustomTokenRequestValidatorTests.cs index 4220c9d20798..a5044ad2c6c5 100644 --- a/test/Identity.Test/IdentityServer/CustomTokenRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/CustomTokenRequestValidatorTests.cs @@ -6,6 +6,7 @@ using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -77,7 +78,8 @@ public CustomTokenRequestValidatorTests() Substitute.For(), Substitute.For(), Substitute.For(), - _updateDeviceLastActivityCommand); + _updateDeviceLastActivityCommand, + Substitute.For()); } private CustomTokenRequestValidationContext CreateRefreshTokenContext(ClaimsPrincipal subject) @@ -261,6 +263,7 @@ public async Task TryUpdateDeviceLastActivityForRefreshAsync_PassesClientVersion Substitute.For>>()); var updateCmd = Substitute.For(); + var issueRememberCmd = Substitute.For(); var sut = new CustomTokenRequestValidator( userManager, @@ -282,7 +285,8 @@ public async Task TryUpdateDeviceLastActivityForRefreshAsync_PassesClientVersion Substitute.For(), Substitute.For(), Substitute.For(), - updateCmd); + updateCmd, + issueRememberCmd); var subject = new ClaimsPrincipal(new ClaimsIdentity( [ diff --git a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs index e49cc6b0bfce..5a7c2b0a5df2 100644 --- a/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/TwoFactorAuthenticationValidatorTests.cs @@ -5,6 +5,7 @@ using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Services; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; @@ -29,6 +30,7 @@ namespace Bit.Identity.Test.IdentityServer; public class TwoFactorAuthenticationValidatorTests { + private const string DeviceIdentifier = "test-device-identifier"; private readonly IUserService _userService; private readonly UserManagerTestWrapper _userManager; private readonly IOrganizationDuoUniversalTokenProvider _organizationDuoUniversalTokenProvider; @@ -37,12 +39,11 @@ public class TwoFactorAuthenticationValidatorTests private readonly IOrganizationRepository _organizationRepository; private readonly IDataProtectorTokenFactory _ssoEmail2faSessionTokenable; private readonly ITwoFactorIsEnabledQuery _twoFactorEnabledQuery; + private readonly IValidateTwoFactorRememberTokenQuery _validateTwoFactorRememberTokenQuery; private readonly ICurrentContext _currentContext; private readonly ITwoFactorEmailService _twoFactorEmailService; private readonly TwoFactorAuthenticationValidator _sut; - private const string DeviceIdentifier = "device-identifier"; - public TwoFactorAuthenticationValidatorTests() { _userService = Substitute.For(); @@ -53,6 +54,7 @@ public TwoFactorAuthenticationValidatorTests() _organizationRepository = Substitute.For(); _ssoEmail2faSessionTokenable = Substitute.For>(); _twoFactorEnabledQuery = Substitute.For(); + _validateTwoFactorRememberTokenQuery = Substitute.For(); _currentContext = Substitute.For(); _twoFactorEmailService = Substitute.For(); @@ -66,7 +68,8 @@ public TwoFactorAuthenticationValidatorTests() _ssoEmail2faSessionTokenable, _twoFactorEnabledQuery, _currentContext, - _twoFactorEmailService); + _twoFactorEmailService, + _validateTwoFactorRememberTokenQuery); } [Theory] @@ -411,7 +414,7 @@ public async void VerifyTwoFactorAsync_Individual_TypeNull_ReturnsFalse( user, null, TwoFactorProviderType.U2f, token, DeviceIdentifier); // Assert - Assert.False(result); + Assert.False(result.Succeeded); } [Theory] @@ -429,7 +432,7 @@ public async void VerifyTwoFactorAsync_Individual_NotEnabled_ReturnsFalse( user, null, TwoFactorProviderType.Email, token, DeviceIdentifier); // Assert - Assert.False(result); + Assert.False(result.Succeeded); await _twoFactorEmailService.DidNotReceiveWithAnyArgs().VerifyTwoFactorLoginTokenAsync(default, default, default); } @@ -447,14 +450,13 @@ public async void VerifyTwoFactorAsync_Organization_NotEnabled_ReturnsFalse( user, null, TwoFactorProviderType.OrganizationDuo, token, DeviceIdentifier); // Assert - Assert.False(result); + Assert.False(result.Succeeded); } [Theory] [BitAutoData(TwoFactorProviderType.Duo)] [BitAutoData(TwoFactorProviderType.WebAuthn)] [BitAutoData(TwoFactorProviderType.YubiKey)] - [BitAutoData(TwoFactorProviderType.Remember)] public async void VerifyTwoFactorAsync_Individual_ValidToken_ReturnsTrue( TwoFactorProviderType providerType, User user, @@ -469,14 +471,141 @@ public async void VerifyTwoFactorAsync_Individual_ValidToken_ReturnsTrue( var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token, DeviceIdentifier); // Assert - Assert.True(result); + Assert.True(result.Succeeded); + } + + /// + /// Remember is not a provider a user configures, so it does not go through the provider-enabled + /// checks or UserManager. It is validated against its own server-side state instead. + /// + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_Remember_DelegatesToRememberTokenQuery(User user, string tokenBody) + { + // Arrange + var token = TwoFactorRememberTokenable.ClearTextPrefix + tokenBody; + _validateTwoFactorRememberTokenQuery.ValidateAsync(user, DeviceIdentifier, token).Returns(true); + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, token, DeviceIdentifier); + + // Assert + Assert.True(result.Succeeded); + await _validateTwoFactorRememberTokenQuery.Received(1).ValidateAsync(user, DeviceIdentifier, token); + } + + // --------------------------------------------------------------------------------------- + // Tokens issued before the current format existed. This whole region goes away with the + // legacy branch once no such token can still be within its lifetime. + // --------------------------------------------------------------------------------------- + + /// A token in the previous format is still honored. + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_LegacyRemember_ValidToken_ReturnsTrue(User user) + { + // Arrange + _userManager.TWO_FACTOR_TOKEN_VERIFIED = true; + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, "legacy-format-token", DeviceIdentifier); + + // Assert + Assert.True(result.Succeeded); + } + + /// + /// The previous format embeds the user's security stamp and the framework provider compares + /// it, so a rotated stamp still rejects these tokens during the transition. + /// + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_LegacyRemember_ProviderRejects_ReturnsFalse(User user) + { + // Arrange + _userManager.TWO_FACTOR_TOKEN_VERIFIED = false; + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, "legacy-format-token", DeviceIdentifier); + + // Assert + Assert.False(result.Succeeded); + Assert.False(result.LegacyRememberUpgradeRequired); + } + + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_LegacyRemember_OrgEnforcesTwoFactor_NoPersonalTwoFactor_ReturnsTrueAndSignalsUpgrade( + User user, Organization organization) + { + // Arrange + organization.Use2fa = true; + organization.TwoFactorProviders = GetTwoFactorOrganizationDuoProviderJson(); + _userManager.TWO_FACTOR_TOKEN_VERIFIED = true; + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, organization, TwoFactorProviderType.Remember, "legacy-format-token", DeviceIdentifier); + + // Assert + Assert.True(result.Succeeded); + Assert.True(result.LegacyRememberUpgradeRequired); + } + + /// Accepting one signals that a replacement should be issued on this response. + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_LegacyRemember_Accepted_SignalsUpgrade(User user) + { + // Arrange + _userManager.TWO_FACTOR_TOKEN_VERIFIED = true; + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, "legacy-format-token", DeviceIdentifier); + + // Assert + Assert.True(result.LegacyRememberUpgradeRequired); + } + + /// + /// A current-format token never reaches the legacy branch, which is what makes removing + /// that branch a no-op for anyone already migrated. + /// + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_CurrentFormat_SkipsLegacyBranchAndSignalsNoUpgrade(User user) + { + // Arrange + var token = TwoFactorRememberTokenable.ClearTextPrefix + "current-format-token"; + _validateTwoFactorRememberTokenQuery.ValidateAsync(user, DeviceIdentifier, token).Returns(true); + _userManager.TWO_FACTOR_TOKEN_VERIFIED = false; + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, token, DeviceIdentifier); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.LegacyRememberUpgradeRequired); + } + + [Theory, BitAutoData] + public async void VerifyTwoFactorAsync_Remember_QueryRejects_ReturnsFalse(User user, string tokenBody) + { + // Arrange + var token = TwoFactorRememberTokenable.ClearTextPrefix + tokenBody; + _validateTwoFactorRememberTokenQuery.ValidateAsync(user, DeviceIdentifier, token).Returns(false); + + // Act + var result = await _sut.VerifyTwoFactorAsync( + user, null, TwoFactorProviderType.Remember, token, DeviceIdentifier); + + // Assert + Assert.False(result.Succeeded); } [Theory] [BitAutoData(TwoFactorProviderType.Duo)] [BitAutoData(TwoFactorProviderType.WebAuthn)] [BitAutoData(TwoFactorProviderType.YubiKey)] - [BitAutoData(TwoFactorProviderType.Remember)] public async void VerifyTwoFactorAsync_Individual_InvalidToken_ReturnsFalse( TwoFactorProviderType providerType, User user, @@ -491,7 +620,7 @@ public async void VerifyTwoFactorAsync_Individual_InvalidToken_ReturnsFalse( var result = await _sut.VerifyTwoFactorAsync(user, null, providerType, token, DeviceIdentifier); // Assert - Assert.False(result); + Assert.False(result.Succeeded); } /// @@ -517,7 +646,7 @@ public async void VerifyTwoFactorAsync_Email_ReturnsEmailServiceResultForRequest user, null, TwoFactorProviderType.Email, token, DeviceIdentifier); // Assert - Assert.Equal(emailServiceResult, result); + Assert.Equal(emailServiceResult, result.Succeeded); } [Theory] @@ -544,7 +673,7 @@ public async void VerifyTwoFactorAsync_Organization_ValidToken_ReturnsTrue( user, organization, providerType, token, DeviceIdentifier); // Assert - Assert.True(result); + Assert.True(result.Succeeded); } [Theory] @@ -564,7 +693,7 @@ public async void VerifyTwoFactorAsync_RecoveryCode_ValidToken_ReturnsTrue( user, organization, providerType, token, DeviceIdentifier); // Assert - Assert.True(result); + Assert.True(result.Succeeded); } [Theory] @@ -585,7 +714,7 @@ public async void VerifyTwoFactorAsync_RecoveryCode_InvalidToken_ReturnsFalse( user, organization, providerType, token, DeviceIdentifier); // Assert - Assert.False(result); + Assert.False(result.Succeeded); } private static UserManagerTestWrapper SubstituteUserManager() diff --git a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs index 310c98d14301..98e1d998a929 100644 --- a/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs +++ b/test/Identity.Test/Wrappers/BaseRequestValidatorTestWrapper.cs @@ -2,6 +2,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.Auth.Repositories; using Bit.Core.Auth.UserFeatures.Devices.Interfaces; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.KeyManagement.Queries.Interfaces; @@ -67,7 +68,8 @@ public BaseRequestValidatorTestWrapper( IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery, IClientVersionValidator clientVersionValidator, - IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand) : + IUpdateDeviceLastActivityCommand updateDeviceLastActivityCommand, + IIssueTwoFactorRememberTokenCommand issueTwoFactorRememberTokenCommand) : base( userManager, userService, @@ -87,7 +89,8 @@ public BaseRequestValidatorTestWrapper( mailService, userAccountKeysQuery, clientVersionValidator, - updateDeviceLastActivityCommand) + updateDeviceLastActivityCommand, + issueTwoFactorRememberTokenCommand) { } diff --git a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs index 45cd74dec7e3..d173ce8f626c 100644 --- a/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs +++ b/test/IntegrationTestCommon/Factories/IdentityApplicationFactory.cs @@ -4,12 +4,14 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Kdf; using Bit.Core.KeyManagement.Models.Api.Request; using Bit.Core.Services; +using Bit.Core.Utilities; using Bit.Identity; using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.RequestValidators; @@ -18,6 +20,7 @@ using Core.Auth.Enums; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using NSubstitute; using Xunit; @@ -150,6 +153,10 @@ public async Task ContextFromPasswordAsync( return context; } + /// + /// "1" asks the server to remember the device. Pass "0" to mirror a client re-authenticating + /// with a stored remember token, which is what clients actually send in that case. + /// public async Task ContextFromPasswordWithTwoFactorAsync( string username, string password, @@ -158,7 +165,8 @@ public async Task ContextFromPasswordWithTwoFactorAsync( DeviceType deviceType = DeviceType.FirefoxBrowser, string deviceName = "firefox", string twoFactorProviderType = "Email", - string twoFactorToken = "two-factor-token") + string twoFactorToken = "two-factor-token", + string twoFactorRemember = "1") { var context = await Server.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { @@ -172,12 +180,56 @@ public async Task ContextFromPasswordWithTwoFactorAsync( { "password", password }, { "TwoFactorToken", twoFactorToken }, { "TwoFactorProvider", twoFactorProviderType }, - { "TwoFactorRemember", "1" }, + { "TwoFactorRemember", twoFactorRemember }, })); return context; } + /// + /// Completes a two-factor login and returns the access token alongside the TwoFactorToken + /// response field, which is the remember token when the server issued one. + /// + /// + /// RememberToken is null when the response carried no TwoFactorToken field at all — + /// a meaningful outcome in its own right, not just an absent value. + /// + public async Task<(string AccessToken, string? RememberToken)> TokensFromPasswordWithTwoFactorAsync( + string username, + string password, + string deviceIdentifier = DefaultDeviceIdentifier, + string clientId = "web", + DeviceType deviceType = DeviceType.FirefoxBrowser, + string deviceName = "firefox", + string twoFactorProviderType = "Email", + string twoFactorToken = "two-factor-token", + string twoFactorRemember = "1") + { + var context = await ContextFromPasswordWithTwoFactorAsync( + username, password, deviceIdentifier, clientId, deviceType, deviceName, + twoFactorProviderType, twoFactorToken, twoFactorRemember); + + using var body = await AssertHelper.AssertResponseTypeIs(context); + var root = body.RootElement; + + Debug.Assert(root.TryGetProperty("access_token", out var accessToken)); + var accessTokenString = accessToken.GetString(); + Debug.Assert(accessTokenString != null); + + var rememberToken = root.TryGetProperty("TwoFactorToken", out var twoFactorTokenElement) + ? twoFactorTokenElement.GetString() + : null; + + return (accessTokenString, rememberToken); + } + + public async Task GenerateLegacyRememberTokenAsync(User user) + { + var userManager = GetService>(); + return await userManager.GenerateTwoFactorTokenAsync( + user, CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); + } + public async Task TokenFromAccessTokenAsync(Guid clientId, string clientSecret, DeviceType deviceType = DeviceType.SDK) {