using FluentAssertions; using EonaCat.SecureToken.Core; using EonaCat.SecureToken.Cryptography; using EonaCat.SecureToken.Extensions; using EonaCat.SecureToken.Validation; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace EonaCat.SecureToken.Tests { public sealed class TokenServiceTests { private static ITokenService CreateService(SigningKeyStore? store = null) => new TokenService(store ?? SigningKeyStore.CreateNew()); // Issue & Validate [Fact] public void Issue_And_Validate_ReturnsSuccess() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-123") .IssuedBy("my-app") .ForAudience("api") .WithRole("admin") .WithClaim("email", "user@example.com")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("my-app", "api")); result.IsSuccess.Should().BeTrue(); var claims = result.UnwrapClaims(); claims.Subject.Should().Be("user-123"); claims.Roles.Should().Contain("admin"); claims.Custom["email"].Should().Be("user@example.com"); } [Fact] public void Validate_TamperedToken_ReturnsInvalidSignature() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create().ForSubject("user-1").IssuedBy("app").ForAudience("api")); // Flip one character in the payload section var parts = token.Split('.'); var corrupted = parts[0] + "." + parts[1].Substring(0, parts[1].Length - 1) + "X" + "." + parts[2]; var result = svc.Validate(corrupted, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } [Fact] public void Validate_ExpiredToken_ReturnsExpired() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .WithLifetime(TimeSpan.FromMilliseconds(1))); Thread.Sleep(50); // let it expire var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, ClockSkew = TimeSpan.Zero, }); result.Should().BeOfType(); } [Fact] public void Validate_WrongAudience_ReturnsWrongAudience() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("service-a")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "service-b")); result.Should().BeOfType(); } [Fact] public void Validate_WrongTokenType_ReturnsWrongTokenType() { var svc = CreateService(); var refreshToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .AsRefreshToken()); // Try to use refresh token where access token is expected var result = svc.Validate(refreshToken, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } [Fact] public void Validate_BindingContextMismatch_ReturnsBindingMismatch() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .BoundTo("192.168.1.1")); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = "10.0.0.1", // different IP }); result.Should().BeOfType(); } [Fact] public void Validate_CorrectBindingContext_ReturnsSuccess() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1") .IssuedBy("app") .ForAudience("api") .BoundTo("192.168.1.1")); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = "192.168.1.1", }); result.IsSuccess.Should().BeTrue(); } // Key Rotation [Fact] public void KeyRotation_OldTokensRemainValid() { var store = SigningKeyStore.CreateNew(); var svc = CreateService(store); var oldToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); // Rotate the signing key store.Rotate(); // Old token must still validate var result = svc.Validate(oldToken, TokenValidationOptions.AccessToken("app", "api")); result.IsSuccess.Should().BeTrue(); // New tokens use the new key var newToken = svc.Issue(TokenDescriptor.Create() .ForSubject("user-2").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(newToken); claims!.KeyGeneration.Should().Be(2); } [Fact] public void KeyRotation_NewTokensUseNewKey() { var store = SigningKeyStore.CreateNew(); var svc = CreateService(store); store.Rotate(); store.Rotate(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token); claims!.KeyGeneration.Should().Be(3); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", "api")); result.IsSuccess.Should().BeTrue(); } // Token Pair [Fact] public void IssueTokenPair_ProducesValidPair() { var svc = CreateService(); var pair = svc.IssueTokenPair("user-1", "app", "api", roles: new[] { "user" }); var accessResult = svc.Validate(pair.AccessToken, TokenValidationOptions.AccessToken("app", "api")); var refreshResult = svc.Validate(pair.RefreshToken, TokenValidationOptions.RefreshToken("app")); accessResult.IsSuccess.Should().BeTrue(); refreshResult.IsSuccess.Should().BeTrue(); } [Fact] public void IssueTokenPair_RefreshToken_CannotBeUsedAsAccessToken() { var svc = CreateService(); var pair = svc.IssueTokenPair("user-1", "app", "api"); // Try to validate refresh token as access token var result = svc.Validate(pair.RefreshToken, TokenValidationOptions.AccessToken("app", "api")); result.Should().BeOfType(); } // Revocation [Fact] public async Task RevocationCheck_RevokedToken_ReturnsRevoked() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token)!; var revokedIds = new HashSet { claims.TokenId }; var options = new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, RevocationCheck = (id, _) => Task.FromResult(revokedIds.Contains(id)), }; var result = await svc.ValidateAsync(token, options); result.Should().BeOfType(); } // Inspect (no signature check) [Fact] public void Inspect_MalformedToken_ReturnsNull() { var svc = CreateService(); svc.Inspect("not.a.valid.token").Should().BeNull(); } [Fact] public void Inspect_ValidToken_ReturnsClaims() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-42").IssuedBy("app").ForAudience("api")); var claims = svc.Inspect(token); claims.Should().NotBeNull(); claims!.Subject.Should().Be("user-42"); } // MaxTokenAge backstop [Fact] public void Validate_WithinMaxTokenAge_ReturnsSuccess() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api") .WithLifetime(TimeSpan.FromHours(2))); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, MaxTokenAge = TimeSpan.FromHours(1), }); // exp claim alone (2h lifetime) would normally accept this, but MaxTokenAge // is a backstop measured from iat, independent of exp. result.IsSuccess.Should().BeTrue(); } [Fact] public void Validate_ExceedsMaxTokenAge_ReturnsTooOld() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api") .WithLifetime(TimeSpan.FromMilliseconds(50))); Thread.Sleep(60); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, ValidateExpiry = false, // isolate the MaxTokenAge check from the normal exp check MaxTokenAge = TimeSpan.FromMilliseconds(10), ClockSkew = TimeSpan.Zero, }); result.Should().BeOfType(); } // Multi-audience accept-list [Fact] public void Validate_MultiAudience_AcceptsAnyMatchingAudience() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("service-b")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", new[] { "service-a", "service-b", "service-c" })); result.IsSuccess.Should().BeTrue(); } [Fact] public void Validate_MultiAudience_RejectsNonMatchingAudience() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("service-z")); var result = svc.Validate(token, TokenValidationOptions.AccessToken("app", new[] { "service-a", "service-b" })); result.Should().BeOfType(); } // Replay cache [Fact] public async Task ValidateAsync_WithReplayCache_FirstUseSucceeds() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app") .OfType(TokenTypeConstants.PasswordReset) .WithLifetime(TimeSpan.FromMinutes(10))); var result = await svc.ValidateAsync(token, new TokenValidationOptions { ValidIssuer = "app", RequiredTokenType = TokenTypeConstants.PasswordReset, ReplayCache = new InMemoryReplayCache(), }); result.IsSuccess.Should().BeTrue(); } [Fact] public async Task ValidateAsync_WithReplayCache_SecondUseIsReplayed() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app") .OfType(TokenTypeConstants.PasswordReset) .WithLifetime(TimeSpan.FromMinutes(10))); var options = new TokenValidationOptions { ValidIssuer = "app", RequiredTokenType = TokenTypeConstants.PasswordReset, ReplayCache = new InMemoryReplayCache(), }; var first = await svc.ValidateAsync(token, options); var second = await svc.ValidateAsync(token, options); first.IsSuccess.Should().BeTrue(); second.Should().BeOfType(); } // OnValidated introspection hook [Fact] public void Validate_OnValidatedHook_InvokedOnSuccessWithClaims() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-99").IssuedBy("app").ForAudience("api")); TokenValidationEvent? captured = null; var options = TokenValidationOptions.AccessToken("app", "api"); options.OnValidated = e => captured = e; svc.Validate(token, options); captured.Should().NotBeNull(); captured!.Result.IsSuccess.Should().BeTrue(); captured.Subject.Should().Be("user-99"); } [Fact] public void Validate_OnValidatedHook_InvokedOnFailureWithNullSubject() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("service-a")); TokenValidationEvent? captured = null; var options = TokenValidationOptions.AccessToken("app", "service-b"); // wrong audience options.OnValidated = e => captured = e; svc.Validate(token, options); captured.Should().NotBeNull(); captured!.Result.Should().BeOfType(); captured.Subject.Should().BeNull(); } // Constant-time binding comparison (functional correctness, not timing) [Fact] public void Validate_BindingContext_DifferentLengthMismatch_ReturnsBindingMismatch() { var svc = CreateService(); var token = svc.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api") .BoundTo("short")); var result = svc.Validate(token, new TokenValidationOptions { ValidIssuer = "app", ValidAudience = "api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = "a-much-longer-binding-context-value", }); result.Should().BeOfType(); } // DI factory-overload regression test [Fact] public void ServiceCollection_AddSecureTokens_WithFactory_ResolvesTokenService() { var services = new ServiceCollection(); var presetKey = new byte[64]; new Random(42).NextBytes(presetKey); services.AddSecureTokens(_ => SigningKeyStore.FromKeys(new[] { (1, presetKey) })); using var provider = services.BuildServiceProvider(); // Before the fix, this threw because the factory overload registered the // Func<> itself rather than invoking it to produce a SigningKeyStore, so // TokenService's constructor dependency could never be satisfied. var tokenService = provider.GetRequiredService(); var keyStore = provider.GetRequiredService(); tokenService.Should().NotBeNull(); keyStore.Should().NotBeNull(); var token = tokenService.Issue(TokenDescriptor.Create() .ForSubject("user-1").IssuedBy("app").ForAudience("api")); var result = tokenService.Validate(token, TokenValidationOptions.AccessToken("app", "api")); result.IsSuccess.Should().BeTrue(); } } }