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(); + } +}