Initial version
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EonaCat.SecureToken\EonaCat.SecureToken.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,261 @@
|
||||
using FluentAssertions;
|
||||
using SecureToken.Core;
|
||||
using SecureToken.Cryptography;
|
||||
using SecureToken.Validation;
|
||||
using Xunit;
|
||||
|
||||
namespace 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<TokenResult.InvalidSignature>();
|
||||
}
|
||||
|
||||
[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<TokenResult.Expired>();
|
||||
}
|
||||
|
||||
[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<TokenResult.WrongAudience>();
|
||||
}
|
||||
|
||||
[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<TokenResult.WrongTokenType>();
|
||||
}
|
||||
|
||||
[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<TokenResult.BindingMismatch>();
|
||||
}
|
||||
|
||||
[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<TokenResult.WrongTokenType>();
|
||||
}
|
||||
|
||||
// ── 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<string> { 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<TokenResult.Revoked>();
|
||||
}
|
||||
|
||||
// ── 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user