feat(core): add password hashing and token generation interfaces

This commit is contained in:
Stevan Freeborn
2026-02-03 04:38:40 -06:00
parent 2a1a03ef6c
commit 98de115259
7 changed files with 161 additions and 2 deletions
+14 -1
View File
@@ -1,3 +1,16 @@
namespace FiscalOS.API.Login;
internal sealed record Response(string AccessToken);
internal sealed record Response
{
public string AccessToken { get; init; }
private Response(string accessToken)
{
AccessToken = accessToken;
}
public static Response From(string accessToken)
{
return new(accessToken);
}
}
@@ -0,0 +1,7 @@
namespace FiscalOS.Core.Authentication;
public interface IPasswordHasher
{
string Hash(string password);
bool Verify(string providedPassword, string hashedPassword);
}
@@ -0,0 +1,7 @@
namespace FiscalOS.Core.Authentication;
public interface ITokenGenerator
{
string GenerateAccessToken(User user);
RefreshToken GenerateRefreshToken(User user);
}
+1 -1
View File
@@ -23,7 +23,7 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
var connectionString = $"{DataSourceKey}{dbPath}";
optionsBuilder.UseSqlite(connectionString)
.AddInterceptors(new TimestampInterceptor());
.AddInterceptors(TimestampInterceptor.New());
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -4,6 +4,15 @@ namespace FiscalOS.Infra.Data;
internal sealed class TimestampInterceptor : SaveChangesInterceptor
{
private TimestampInterceptor()
{
}
public static TimestampInterceptor New()
{
return new();
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
UpdateEntities(eventData.Context);
@@ -0,0 +1,45 @@
namespace FiscalOS.Infra.Tests.Assertions;
internal static class JwtTokenAssertionExtensions
{
public static AndConstraint<string> HaveClaim(
this StringAssertions assertions,
string claimType
)
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(assertions.Subject);
var claim = jwtToken.Claims.FirstOrDefault(c => c.Type == claimType);
assertions.CurrentAssertionChain
.ForCondition(claim is not null)
.FailWith($"Expected JWT token to have claim '{claimType}', but it was not found.");
return new AndConstraint<string>(assertions.Subject);
}
public static AndConstraint<string> HaveClaimWithValue(
this StringAssertions assertions,
string claimType,
string expectedValue
)
{
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(assertions.Subject);
var claim = jwtToken.Claims.FirstOrDefault(c => c.Type == claimType);
assertions.CurrentAssertionChain
.ForCondition(claim is not null)
.FailWith($"Expected JWT token to have claim '{claimType}', but it was not found.");
assertions.CurrentAssertionChain
.ForCondition(claim!.Value == expectedValue)
.FailWith(
$"Expected JWT token claim '{claimType}' to have value '{expectedValue}', but found '{claim.Value}'."
);
return new AndConstraint<string>(assertions.Subject);
}
}
@@ -0,0 +1,78 @@
using System.Globalization;
using System.Security.Cryptography;
namespace FiscalOS.Infra.Tests.Unit;
public class TokenGeneratorTests
{
private readonly Mock<IOptions<JwtOptions>> _mockJwtOptions = new();
private readonly Mock<TimeProvider> _mockTimeProvider = new();
private readonly TokenGenerator _sut;
public TokenGeneratorTests()
{
_sut = TokenGenerator.From(
_mockTimeProvider.Object,
_mockJwtOptions.Object
);
}
[Fact]
public void GenerateAccessToken_WhenCalled_ItShouldReturnValidJwtToken()
{
var user = User.From("testuser", "hashedpassword");
var madeUp256BitSecret = RandomNumberGenerator.GetBytes(32);
var jwtOptions = new JwtOptions
{
Secret = Convert.ToBase64String(madeUp256BitSecret),
Issuer = "TestIssuer",
Audience = "TestAudience",
ExpiryInMinutes = 60,
};
_mockJwtOptions
.SetupGet(static x => x.Value)
.Returns(jwtOptions);
var now = DateTimeOffset.UtcNow;
_mockTimeProvider
.Setup(static x => x.GetUtcNow())
.Returns(now);
var token = _sut.GenerateAccessToken(user);
token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Sub, user.Id.ToString());
token.Should().HaveClaim(JwtRegisteredClaimNames.Jti);
token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Iss, jwtOptions.Issuer);
token.Should().HaveClaimWithValue(JwtRegisteredClaimNames.Aud, jwtOptions.Audience);
token.Should().HaveClaimWithValue(
JwtRegisteredClaimNames.Exp,
new DateTimeOffset(
now.AddMinutes(jwtOptions.ExpiryInMinutes).UtcDateTime
)
.ToUnixTimeSeconds()
.ToString(CultureInfo.InvariantCulture)
);
}
[Fact]
public void GenerateRefreshToken_WhenCalled_ItShouldReturnRefreshTokenWithCorrectProperties()
{
var user = User.From("testuser", "hashedpassword");
var now = DateTimeOffset.UtcNow;
_mockTimeProvider
.Setup(static x => x.GetUtcNow())
.Returns(now);
var refreshToken = _sut.GenerateRefreshToken(user);
refreshToken.UserId.Should().Be(user.Id);
refreshToken.ExpiresAt.Should().Be(now.AddHours(12));
refreshToken.Token.Should().NotBeNullOrEmpty();
}
}