1
0
mirror of https://github.com/bitwarden/server synced 2025-12-15 15:53:59 +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,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);
}
}