1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00

[PM-19055] Add OTP Token Provider that is not dependent on the User entity (#6081)

* feat(pm-19055) : 
  - Add generic OTP generator. This OTP generator is not linked to .NET Identity giving us flexibility.
  - Update `OtpTokenProvider` to accept configuration object to keep interface clean.
  - Implement `OtpTokenProvider` in DI as open generic for flexibility.
* test: 100% test coverage for `OtpTokenProvider`
* doc: Added readme for `OtpTokenProvider`
This commit is contained in:
Ike
2025-07-17 17:44:20 -04:00
committed by GitHub
parent ec70a18bda
commit 828003f101
6 changed files with 807 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// A generic interface for a one-time password (OTP) token provider.
/// </summary>
public interface IOtpTokenProvider<TOptions>
where TOptions : DefaultOtpTokenProviderOptions
{
/// <summary>
/// Generates a new one-time password (OTP) based on the configured parameters.
/// The generated OTP is stored in the distributed cache with a key based on the unique identifier and purpose. If the
/// key is already in use, it will overwrite and generate a new OTP with a refreshed TTL.
/// </summary>
/// <param name="tokenProviderName">Name of the token provider, used to distinguish different token providers that may inject this class</param>
/// <param name="purpose">Purpose of the OTP token, used to distinguish different types of tokens.</param>
/// <param name="uniqueIdentifier">Unique identifier to distinguish one request from another</param>
/// <returns>generated token | null</returns>
Task<string?> GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier);
/// <summary>
/// Validates the provided token against the stored value in the distributed cache.
/// </summary>
/// <param name="token">string value matched against the unique identifier in the cache if found</param>
/// <param name="tokenProviderName">Name of the token provider, used to distinguish different token providers that may inject this class</param>
/// <param name="purpose">Purpose of the OTP token, used to distinguish different types of tokens.</param>
/// <param name="uniqueIdentifier">Unique identifier to distinguish one request from another</param>
/// <returns>true if the token matches what is fetched from the cache, false if not.</returns>
Task<bool> ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier);
}

View File

@@ -0,0 +1,75 @@
using System.Text;
using Bit.Core.Utilities;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bit.Core.Auth.Identity.TokenProviders;
public class OtpTokenProvider<TOptions>(
[FromKeyedServices("persistent")]
IDistributedCache distributedCache,
IOptions<TOptions> options) : IOtpTokenProvider<TOptions>
where TOptions : DefaultOtpTokenProviderOptions
{
private readonly TOptions _otpTokenProviderOptions = options.Value;
/// <summary>
/// This is where the OTP tokens are stored.
/// </summary>
private readonly IDistributedCache _distributedCache = distributedCache;
/// <summary>
/// Used to store and fetch the OTP tokens from the distributed cache.
/// The format is "{tokenProviderName}_{purpose}_{uniqueIdentifier}".
/// </summary>
private readonly string _cacheKeyFormat = "{0}_{1}_{2}";
public async Task<string?> GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier)
{
if (string.IsNullOrEmpty(tokenProviderName)
|| string.IsNullOrEmpty(purpose)
|| string.IsNullOrEmpty(uniqueIdentifier))
{
return null;
}
var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier);
var token = CoreHelpers.SecureRandomString(
_otpTokenProviderOptions.TokenLength,
_otpTokenProviderOptions.TokenAlpha,
true,
false,
_otpTokenProviderOptions.TokenNumeric,
false);
await _distributedCache.SetAsync(cacheKey, Encoding.UTF8.GetBytes(token), _otpTokenProviderOptions.DistributedCacheEntryOptions);
return token;
}
public async Task<bool> ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier)
{
if (string.IsNullOrEmpty(token)
|| string.IsNullOrEmpty(tokenProviderName)
|| string.IsNullOrEmpty(purpose)
|| string.IsNullOrEmpty(uniqueIdentifier))
{
return false;
}
var cacheKey = string.Format(_cacheKeyFormat, tokenProviderName, purpose, uniqueIdentifier);
var cachedValue = await _distributedCache.GetAsync(cacheKey);
if (cachedValue == null)
{
return false;
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);
}
return valid;
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.Extensions.Caching.Distributed;
namespace Bit.Core.Auth.Identity.TokenProviders;
/// <summary>
/// Options for configuring the OTP token provider.
/// </summary>
public class DefaultOtpTokenProviderOptions
{
/// <summary>
/// Gets or sets the length of the generated token.
/// Default is 6 characters.
/// </summary>
public int TokenLength { get; set; } = 6;
/// <summary>
/// Gets or sets whether the token should contain alphabetic characters.
/// Default is false.
/// </summary>
public bool TokenAlpha { get; set; } = false;
/// <summary>
/// Gets or sets whether the token should contain numeric characters.
/// Default is true.
/// </summary>
public bool TokenNumeric { get; set; } = true;
/// <summary>
/// Cache entry options for Otp Token provider.
/// Default is 5 minutes expiration.
/// </summary>
public DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
};
}

View File

@@ -0,0 +1,206 @@
# OtpTokenProvider
The `OtpTokenProvider` is a token provider service for generating and validating Time-Based one-time passwords (TOTP). It provides a secure way to create temporary tokens for various authentication and verification scenarios. The provider can be configured to generate tokens specific to your use case by using the options pattern in the DI pipeline.
## Overview
The OTP Token Provider generates secure, time-limited tokens that can be used for:
- Two-factor authentication
- Temporary access tokens for Sends
- Any scenario requiring short-lived verification codes
## Features
- **Configurable Token Length**: Default 6 characters, customizable
- **Character Set Options**: Numeric (default), alphabetic, or mixed
- **Distributed Caching**: Uses CosmosDb for cloud, or the configured database otherwise.
- **TTL Management**: Configurable expiration (default 5 minutes)
- **Secure Generation**: Uses cryptographically secure random generation
- **One-Time Use**: Tokens are automatically deleted from the cache after successful validation
## Architecture
### Interface: `IOtpTokenProvider<TOptions>`
```csharp
public interface IOtpTokenProvider<TOptions>
where TOptions : DefaultOtpTokenProviderOptions
{
Task<string?> GenerateTokenAsync(string tokenProviderName, string purpose, string uniqueIdentifier);
Task<bool> ValidateTokenAsync(string token, string tokenProviderName, string purpose, string uniqueIdentifier);
}
```
### Implementation: `OtpTokenProvider`
The provider is initialized with:
- **Distributed Cache**: Storage backend for tokens (using "persistent" keyed service)
- **IOptions<TOptions>**: Configuration options for token generation and caching
## Usage
### Basic Setup
If your class needs the use the `IOtpTokenProvider` you can inject it like any other injectable class from the DI.
### Generating a Token
```csharp
// Generate a new OTP with token provider name, purpose and unique identifier
string token = await otpProvider.GenerateTokenAsync("EmailToken", "email_verification", $"{userId}_{securityStamp}");
// Returns: "123456" (6-digit numeric by default)
```
### Validating a Token
```csharp
// Validate user-provided token with same parameters used for generation
bool isValid = await otpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", $"{userId}_{securityStamp}");
// Returns: true if valid, false otherwise
// Note: Valid tokens are automatically removed from cache
```
### Custom Configurations
If you need to modify the default options you can do so by creating an extension of the `DefaultOtpTokenProviderOptions` and using that class as the TOptions when injecting another IOtpTokenProvider service.
#### OtpTokenProviderOptions
```csharp
public class DefaultOtpTokenProviderOptions
{ ... }
public class UserEmailOtpTokenOptions : DefaultOtpTokenProviderOptions { }
```
#### Service Collection
```csharp
public static IdentityBuilder AddCustomIdentityServices(
this IServiceCollection services, GlobalSettings globalSettings)
{
// possible customization
services.Configure<UserEmailOtpTokenOptions>(options =>
{
options.TokenLength = 8;
// The other options are left default
});
// TryAddTransient open generics -> this allows us to inject IOtpTokenProvider<T> without having to specify the specific type here.
services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>);
}
```
#### Usage
```csharp
public class UserEmailTokenProvider(
IOtpTokenProvider<UserEmailOtpTokenOptions> otpTokenProvider
)
{
private readonly IOtpTokenProvider<UserEmailOtpTokenOptions> _otpTokenProvider = otpTokenProvider;
...
}
```
## Configuration Options
### Token Properties
| Property | Default | Description |
| -------------- | ------- | ---------------------------------------- |
| `TokenLength` | 6 | Number of characters in generated token |
| `TokenAlpha` | false | Include alphabetic characters (a-z, A-Z) |
| `TokenNumeric` | true | Include numeric characters (0-9) |
### Cache Options
See `DistributedCacheEntryOptions` documentation for a complete list of configuration options.
| Property | Default | Description |
| --------------------------------- | --------- | ---------------------------- |
| `AbsoluteExpirationRelativeToNow` | 5 minutes | How long tokens remain valid |
## Cache Key Format
The cache key format uses three components: `{tokenProviderName}_{purpose}_{uniqueIdentifier}`
### Examples:
#### Possible Email Token Provider Example
Email token provider uses:
- **Token Provider Name**: `"EmailToken"` (identifies the specific use case)
- **Purpose**: `"EmailTwoFactorAuthentication"` (specific action being verified)
- **Unique Identifier**: `"{user.Id}_{securityStamp}"` (user-specific data)
These are passed into the OTP Token Provider which creates a cache record:
- Cache Key: `EmailToken_EmailTwoFactorAuthentication_guid_guid`
## Security Considerations
### Token Generation
- Uses `CoreHelpers.SecureRandomString()` for cryptographically secure randomness
- No predictable patterns in generated tokens
- Configurable character sets for different security requirements
### Storage
- Tokens are stored in distributed cache. The cache depends on the specific deployment, for cloud it is CosmosDb.
- Automatic expiration prevents indefinite token validity
- One-time use prevents replay attacks
### Validation
- Exact string matching for validation
- Automatic removal after successful validation
- Returns `false` for expired or non-existent tokens
## Dependency Injection
The provider is registered in `ServiceCollectionExtensions.cs`:
```csharp
services.TryAddScoped<IOtpTokenProvider<TOptions>, OtpTokenProvider<TOptions>>();
```
## Error Handling
### Common Scenarios
- **Token Not Found**: `ValidateTokenAsync()` returns `false`
- **Token Expired**: Automatically cleaned up by cache, validation returns `false`
- **Invalid Input**:
- `GenerateTokenAsync` returns `null` for empty/null tokenProviderName, purpose, or uniqueIdentifier
- `ValidateTokenAsync` returns `false` for empty/null token, tokenProviderName, purpose, or uniqueIdentifier
- No cache operations are performed for invalid inputs
### Best Practices
- Always check validation results
- Handle token expiration gracefully
- Provide clear user feedback for invalid tokens
- Implement rate limiting for token generation
## Related Components
- **`CoreHelpers.SecureRandomString()`**: Secure token generation
- **`IDistributedCache`**: Token storage backend
- **Two-Factor Authentication Providers**: Integration with 2FA flows
- **Email Services**: A Token delivery mechanism
## Testing
When testing components that use `OtpTokenProvider`:
```csharp
// Mock the interface for unit tests
var mockOtpProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
mockOtpProvider.GenerateTokenAsync("EmailToken", "email_verification", "user_123").Returns("123456");
mockOtpProvider.ValidateTokenAsync("123456", "EmailToken", "email_verification", "user_123").Returns(true);
```

View File

@@ -378,6 +378,8 @@ public static class ServiceCollectionExtensions
public static IdentityBuilder AddCustomIdentityServices(
this IServiceCollection services, GlobalSettings globalSettings)
{
services.TryAddTransient(typeof(IOtpTokenProvider<>), typeof(OtpTokenProvider<>));
services.AddScoped<IOrganizationDuoUniversalTokenProvider, OrganizationDuoUniversalTokenProvider>();
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 100000);
services.Configure<TwoFactorRememberTokenProviderOptions>(options =>

View File

@@ -0,0 +1,459 @@
using System.Text;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Auth.Identity;
[SutProviderCustomize]
public class OtpTokenProviderTests
{
private readonly string _defaultTokenProviderName = "DefaultOtpProvider";
private readonly DefaultOtpTokenProviderOptions _defaultOtpTokenProviderOptions = new()
{
TokenLength = 6,
TokenAlpha = false,
TokenNumeric = true
};
[Theory, BitAutoData]
public async Task GenerateTokenAsync_Success_ReturnsToken(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(_defaultOtpTokenProviderOptions);
sutProvider.Create();
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.NotNull(result);
Assert.NotEmpty(result);
Assert.Equal(6, result.Length); // Default length
Assert.True(result.All(char.IsDigit)); // Default is numeric only
// Verify cache was called with correct key
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_CustomConfiguration_ReturnsCorrectFormat(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string tokenProviderName,
string purpose,
string uniqueIdentifier)
{
// Arrange
var otpConfig = new DefaultOtpTokenProviderOptions
{
TokenLength = 8,
TokenAlpha = true,
TokenNumeric = true
};
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(otpConfig);
sutProvider.Create();
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(tokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.NotNull(result);
Assert.Equal(8, result.Length);
Assert.Contains(result, char.IsLetterOrDigit);
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_NumericOnly_ReturnsOnlyDigits(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
var otpConfig = new DefaultOtpTokenProviderOptions
{
TokenLength = 10,
TokenAlpha = false,
TokenNumeric = true
};
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(otpConfig);
sutProvider.Create();
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.Equal(10, result.Length);
Assert.True(result.All(char.IsDigit));
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_ValidToken_ReturnsTrue(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier,
string token)
{
// Arrange
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
var tokenBytes = Encoding.UTF8.GetBytes(token);
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns(tokenBytes);
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.True(result);
// Verify token was removed from cache after successful validation
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.RemoveAsync(expectedCacheKey);
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_InvalidToken_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier,
string token,
string wrongToken)
{
// Arrange
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
var tokenBytes = Encoding.UTF8.GetBytes(wrongToken); // Different token in cache
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns(tokenBytes);
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.False(result);
// Verify token was NOT removed from cache for invalid validation
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.RemoveAsync(expectedCacheKey);
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_TokenNotFound_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier,
string token)
{
// Arrange
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns((byte[])null); // Token not found in cache
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.False(result);
// Verify removal was not attempted
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.RemoveAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_EmptyToken_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync("", _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.False(result);
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_NullToken_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(null, _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.False(result);
}
// Tests for null/empty purpose and uniqueIdentifier parameters
[Theory, BitAutoData]
public async Task GenerateTokenAsync_NullPurpose_ReturnsNull(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, null, uniqueIdentifier);
// Assert
Assert.Null(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_EmptyPurpose_ReturnsNull(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, "", uniqueIdentifier);
// Assert
Assert.Null(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_NullUniqueIdentifier_ReturnsNull(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose)
{
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, null);
// Assert
Assert.Null(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_EmptyUniqueIdentifier_ReturnsNull(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose)
{
// Act
var result = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, "");
// Assert
Assert.Null(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.SetAsync(Arg.Any<string>(), Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_NullPurpose_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string token,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, null, uniqueIdentifier);
// Assert
Assert.False(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.GetAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_EmptyPurpose_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string token,
string uniqueIdentifier)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, "", uniqueIdentifier);
// Assert
Assert.False(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.GetAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_NullUniqueIdentifier_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string token,
string purpose)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, null);
// Assert
Assert.False(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.GetAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_EmptyUniqueIdentifier_ReturnsFalse(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string token,
string purpose)
{
// Act
var result = await sutProvider.Sut.ValidateTokenAsync(token, _defaultTokenProviderName, purpose, "");
// Assert
Assert.False(result);
// Verify cache was not called
await sutProvider.GetDependency<IDistributedCache>()
.DidNotReceive()
.GetAsync(Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task GenerateTokenAsync_OverwritesExistingToken(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(_defaultOtpTokenProviderOptions);
sutProvider.Create();
// Act - Generate token twice with same parameters
var firstToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
var secondToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.NotEqual(firstToken, secondToken); // Should be different tokens
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
await sutProvider.GetDependency<IDistributedCache>()
.Received(2) // Called twice - once for each generation
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task CacheKeyFormat_IsCorrect(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(_defaultOtpTokenProviderOptions);
sutProvider.Create();
// Act
await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
await sutProvider.GetDependency<IDistributedCache>()
.Received(1)
.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>());
}
[Theory, BitAutoData]
public async Task ValidateTokenAsync_CaseSensitive(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
var token = "ABC123";
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
var tokenBytes = Encoding.UTF8.GetBytes(token);
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns(tokenBytes);
// Act & Assert
var validResult = await sutProvider.Sut.ValidateTokenAsync("ABC123", _defaultTokenProviderName, purpose, uniqueIdentifier);
Assert.True(validResult);
// Reset the cache mock to return the token again
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns(tokenBytes);
var invalidResult = await sutProvider.Sut.ValidateTokenAsync("abc123", _defaultTokenProviderName, purpose, uniqueIdentifier);
Assert.False(invalidResult);
}
[Theory, BitAutoData]
public async Task RoundTrip_GenerateAndValidate_Success(
SutProvider<OtpTokenProvider<DefaultOtpTokenProviderOptions>> sutProvider,
string purpose,
string uniqueIdentifier)
{
// Arrange
sutProvider.GetDependency<IOptions<DefaultOtpTokenProviderOptions>>()
.Value.Returns(_defaultOtpTokenProviderOptions);
sutProvider.Create();
var expectedCacheKey = $"{_defaultTokenProviderName}_{purpose}_{uniqueIdentifier}";
byte[] storedToken = null;
// Setup cache to capture stored token and return it on get
sutProvider.GetDependency<IDistributedCache>()
.When(x => x.SetAsync(expectedCacheKey, Arg.Any<byte[]>(), Arg.Any<DistributedCacheEntryOptions>()))
.Do(callInfo => storedToken = callInfo.ArgAt<byte[]>(1));
sutProvider.GetDependency<IDistributedCache>()
.GetAsync(expectedCacheKey)
.Returns(callInfo => storedToken);
// Act
var generatedToken = await sutProvider.Sut.GenerateTokenAsync(_defaultTokenProviderName, purpose, uniqueIdentifier);
var isValid = await sutProvider.Sut.ValidateTokenAsync(generatedToken, _defaultTokenProviderName, purpose, uniqueIdentifier);
// Assert
Assert.True(isValid);
Assert.NotNull(generatedToken);
Assert.NotEmpty(generatedToken);
}
}