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)
{