diff --git a/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj
new file mode 100644
index 0000000..ffa3eaa
--- /dev/null
+++ b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/API/ROLAC.API.Tests/Services/TokenServiceTests.cs b/API/ROLAC.API.Tests/Services/TokenServiceTests.cs
new file mode 100644
index 0000000..5d7cc02
--- /dev/null
+++ b/API/ROLAC.API.Tests/Services/TokenServiceTests.cs
@@ -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
+ {
+ { "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
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Base64Url-decodes the payload segment of a JWT and returns it as a
+ /// . Caller is responsible for disposing.
+ ///
+ 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 { "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 { 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());
+
+ 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')));
+ }
+}
diff --git a/API/ROLAC.API/Services/ITokenService.cs b/API/ROLAC.API/Services/ITokenService.cs
new file mode 100644
index 0000000..55485aa
--- /dev/null
+++ b/API/ROLAC.API/Services/ITokenService.cs
@@ -0,0 +1,15 @@
+using ROLAC.API.Entities;
+
+namespace ROLAC.API.Services;
+
+public interface ITokenService
+{
+ /// Generates a signed HS256 JWT containing userId, email, and roles claims.
+ string GenerateAccessToken(AppUser user, IList roles);
+
+ /// Generates a cryptographically-random 64-byte base64 string (the raw token value).
+ string GenerateRefreshToken();
+
+ /// Returns the SHA-256 hex hash of the raw token. Always hash before storing to DB.
+ string HashToken(string rawToken);
+}
diff --git a/API/ROLAC.API/Services/TokenService.cs b/API/ROLAC.API/Services/TokenService.cs
new file mode 100644
index 0000000..a67624e
--- /dev/null
+++ b/API/ROLAC.API/Services/TokenService.cs
@@ -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 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
+ {
+ 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();
+ }
+}