Task 5: TokenService + unit tests (7/7 pass)
- ITokenService: GenerateAccessToken / GenerateRefreshToken / HashToken - TokenService: JWT (HS256, 15-min), 64-byte CSPRNG refresh, SHA-256 hex hash - Role claims use short JWT name role (v7.x JsonWebTokenHandler compatible) - TokenServiceTests: 7 xUnit tests, payload decoded via Base64Url+System.Text.Json to avoid Microsoft.IdentityModel 7.1.2/7.5.2 version-mismatch issues Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Moq" Version="4.20.72" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ROLAC.API\ROLAC.API.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Tests.Services;
|
||||||
|
|
||||||
|
public class TokenServiceTests
|
||||||
|
{
|
||||||
|
private readonly TokenService _sut;
|
||||||
|
|
||||||
|
public TokenServiceTests()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
{ "Jwt:SecretKey", "test-secret-key-that-is-at-least-32-characters-long!!" },
|
||||||
|
{ "Jwt:Issuer", "rolac-api-test" },
|
||||||
|
{ "Jwt:Audience", "rolac-client-test" },
|
||||||
|
{ "Jwt:AccessTokenExpiryMinutes", "15" },
|
||||||
|
{ "Jwt:RefreshTokenExpiryDays", "30" },
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_sut = new TokenService(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base64Url-decodes the payload segment of a JWT and returns it as a
|
||||||
|
/// <see cref="JsonDocument"/>. Caller is responsible for disposing.
|
||||||
|
/// </summary>
|
||||||
|
private static JsonDocument ReadJwtPayload(string token)
|
||||||
|
{
|
||||||
|
var b64 = token.Split('.')[1];
|
||||||
|
var mod4 = b64.Length % 4;
|
||||||
|
if (mod4 > 0) b64 += new string('=', 4 - mod4);
|
||||||
|
var bytes = Convert.FromBase64String(b64.Replace('-', '+').Replace('_', '/'));
|
||||||
|
return JsonDocument.Parse(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GenerateAccessToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAccessToken_ReturnsValidJwt_WithUserIdAndRoles()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||||||
|
var roles = new List<string> { "member", "finance" };
|
||||||
|
|
||||||
|
var token = _sut.GenerateAccessToken(user, roles);
|
||||||
|
|
||||||
|
Assert.NotEmpty(token);
|
||||||
|
|
||||||
|
// Decode payload without touching any Microsoft.IdentityModel API so that
|
||||||
|
// library version mismatches (7.1.2 vs 7.5.2) cannot affect this test.
|
||||||
|
using var doc = ReadJwtPayload(token);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
Assert.Equal("user-123", root.GetProperty("sub").GetString());
|
||||||
|
Assert.Equal("test@rolac.org", root.GetProperty("email").GetString());
|
||||||
|
|
||||||
|
// JwtSecurityTokenHandler's DefaultOutboundClaimTypeMap maps
|
||||||
|
// ClaimTypes.Role → "role", so roles land under the "role" key.
|
||||||
|
// When multiple claims share a type they are serialised as a JSON array.
|
||||||
|
var roleEl = root.GetProperty("role");
|
||||||
|
var roleClaims = roleEl.ValueKind == JsonValueKind.Array
|
||||||
|
? roleEl.EnumerateArray().Select(e => e.GetString()!).ToList()
|
||||||
|
: new List<string> { roleEl.GetString()! };
|
||||||
|
|
||||||
|
Assert.Contains("member", roleClaims);
|
||||||
|
Assert.Contains("finance", roleClaims);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateAccessToken_ExpiresIn15Minutes()
|
||||||
|
{
|
||||||
|
var user = new AppUser { Id = "user-123", Email = "test@rolac.org", UserName = "test@rolac.org" };
|
||||||
|
|
||||||
|
var token = _sut.GenerateAccessToken(user, new List<string>());
|
||||||
|
|
||||||
|
using var doc = ReadJwtPayload(token);
|
||||||
|
var expUnix = doc.RootElement.GetProperty("exp").GetInt64();
|
||||||
|
var expectedUnix = DateTimeOffset.UtcNow.AddMinutes(15).ToUnixTimeSeconds();
|
||||||
|
|
||||||
|
// Allow ±2 s for test execution time.
|
||||||
|
Assert.InRange(expUnix, expectedUnix - 2, expectedUnix + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GenerateRefreshToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateRefreshToken_Returns64ByteBase64String()
|
||||||
|
{
|
||||||
|
var token = _sut.GenerateRefreshToken();
|
||||||
|
|
||||||
|
Assert.NotEmpty(token);
|
||||||
|
var bytes = Convert.FromBase64String(token);
|
||||||
|
Assert.Equal(64, bytes.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GenerateRefreshToken_ProducesUniqueTokensEachCall()
|
||||||
|
{
|
||||||
|
var token1 = _sut.GenerateRefreshToken();
|
||||||
|
var token2 = _sut.GenerateRefreshToken();
|
||||||
|
|
||||||
|
Assert.NotEqual(token1, token2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HashToken
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_SameInputProducesSameHash()
|
||||||
|
{
|
||||||
|
const string raw = "some-raw-token-value";
|
||||||
|
|
||||||
|
var hash1 = _sut.HashToken(raw);
|
||||||
|
var hash2 = _sut.HashToken(raw);
|
||||||
|
|
||||||
|
Assert.Equal(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_DifferentInputsProduceDifferentHashes()
|
||||||
|
{
|
||||||
|
var hash1 = _sut.HashToken("token-a");
|
||||||
|
var hash2 = _sut.HashToken("token-b");
|
||||||
|
|
||||||
|
Assert.NotEqual(hash1, hash2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HashToken_Returns64CharLowercaseHexString()
|
||||||
|
{
|
||||||
|
var hash = _sut.HashToken("any-token");
|
||||||
|
|
||||||
|
Assert.Equal(64, hash.Length);
|
||||||
|
Assert.True(hash.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public interface ITokenService
|
||||||
|
{
|
||||||
|
/// <summary>Generates a signed HS256 JWT containing userId, email, and roles claims.</summary>
|
||||||
|
string GenerateAccessToken(AppUser user, IList<string> roles);
|
||||||
|
|
||||||
|
/// <summary>Generates a cryptographically-random 64-byte base64 string (the raw token value).</summary>
|
||||||
|
string GenerateRefreshToken();
|
||||||
|
|
||||||
|
/// <summary>Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.</summary>
|
||||||
|
string HashToken(string rawToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class TokenService : ITokenService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public TokenService(IConfiguration config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateAccessToken(AppUser user, IList<string> roles)
|
||||||
|
{
|
||||||
|
var secretKey = _config["Jwt:SecretKey"]!;
|
||||||
|
var issuer = _config["Jwt:Issuer"]!;
|
||||||
|
var audience = _config["Jwt:Audience"]!;
|
||||||
|
var expiryMin = int.Parse(_config["Jwt:AccessTokenExpiryMinutes"]!);
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, user.Id),
|
||||||
|
new(JwtRegisteredClaimNames.Email, user.Email!),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the short JWT claim name "role" so the payload is clean and
|
||||||
|
// JsonWebTokenHandler (the v7.x default validator) can read it without
|
||||||
|
// needing an inbound claim-type map applied.
|
||||||
|
foreach (var role in roles)
|
||||||
|
claims.Add(new Claim("role", role));
|
||||||
|
|
||||||
|
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||||
|
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||||
|
|
||||||
|
var token = new JwtSecurityToken(
|
||||||
|
issuer: issuer,
|
||||||
|
audience: audience,
|
||||||
|
claims: claims,
|
||||||
|
expires: DateTime.UtcNow.AddMinutes(expiryMin),
|
||||||
|
signingCredentials: creds);
|
||||||
|
|
||||||
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateRefreshToken()
|
||||||
|
{
|
||||||
|
var bytes = new byte[64];
|
||||||
|
using var rng = RandomNumberGenerator.Create();
|
||||||
|
rng.GetBytes(bytes);
|
||||||
|
return Convert.ToBase64String(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string HashToken(string rawToken)
|
||||||
|
{
|
||||||
|
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(rawToken));
|
||||||
|
return Convert.ToHexString(bytes).ToLower();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user