Updated
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="xunit" Version="2.7.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.7" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -252,5 +254,205 @@ namespace EonaCat.SecureToken.Tests
|
||||
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<TokenResult.TooOld>();
|
||||
}
|
||||
|
||||
// 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<TokenResult.WrongAudience>();
|
||||
}
|
||||
|
||||
// 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<TokenResult.Replayed>();
|
||||
}
|
||||
|
||||
// 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<TokenResult.WrongAudience>();
|
||||
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<TokenResult.BindingMismatch>();
|
||||
}
|
||||
|
||||
// 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<ITokenService>();
|
||||
var keyStore = provider.GetRequiredService<SigningKeyStore>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.SecureToken/</PackageProjectUrl>
|
||||
<Description>EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens.
|
||||
Secure, modern token library for .NET with key rotation, signing isolation and validation rules</Description>
|
||||
<PackageReleaseNotes>Public release version</PackageReleaseNotes>
|
||||
<PackageReleaseNotes></PackageReleaseNotes>
|
||||
<Copyright>EonaCat (Jeroen Saey)</Copyright>
|
||||
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
|
||||
<PackageTags>EonaCat;authentication;api;micro;services;microservices;rotation;secure;token;validation;jwt;security;secure;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
|
||||
<PackageIconUrl />
|
||||
<FileVersion>0.0.2</FileVersion>
|
||||
<FileVersion>0.0.3</FileVersion>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<PackageLicenseFile>LICENSE</PackageLicenseFile>
|
||||
@@ -25,7 +25,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EVRevisionFormat>0.0.2+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVRevisionFormat>0.0.3+{chash:10}.{c:ymd}</EVRevisionFormat>
|
||||
<EVDefault>true</EVDefault>
|
||||
<EVInfo>true</EVInfo>
|
||||
<EVTagMatch>v[0-9]*</EVTagMatch>
|
||||
@@ -36,7 +36,7 @@ Secure, modern token library for .NET with key rotation, signing isolation and v
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>0.0.2</Version>
|
||||
<Version>0.0.3</Version>
|
||||
<PackageId>EonaCat.SecureToken</PackageId>
|
||||
<Product>EonaCat.SecureToken</Product>
|
||||
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using EonaCat.SecureToken.Cryptography;
|
||||
using EonaCat.SecureToken.Validation;
|
||||
using System;
|
||||
|
||||
namespace EonaCat.SecureToken.Extensions
|
||||
@@ -26,9 +27,7 @@ namespace EonaCat.SecureToken.Extensions
|
||||
/// <summary>
|
||||
/// Adds SecureToken services with a supplied key store.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokens(
|
||||
this IServiceCollection services,
|
||||
SigningKeyStore keyStore)
|
||||
public static IServiceCollection AddSecureTokens(this IServiceCollection services, SigningKeyStore keyStore)
|
||||
{
|
||||
services.AddSingleton(keyStore);
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
@@ -39,13 +38,32 @@ namespace EonaCat.SecureToken.Extensions
|
||||
/// Adds SecureToken services with a factory function to configure the key store.
|
||||
/// Useful when loading keys from environment variables or a secret manager.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokens(
|
||||
this IServiceCollection services,
|
||||
Func<IServiceProvider, SigningKeyStore> keyStoreFactory)
|
||||
public static IServiceCollection AddSecureTokens(this IServiceCollection services, Func<IServiceProvider, SigningKeyStore> keyStoreFactory)
|
||||
{
|
||||
services.AddSingleton(keyStoreFactory);
|
||||
if (keyStoreFactory is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(keyStoreFactory));
|
||||
}
|
||||
|
||||
// Register SigningKeyStore as the singleton, invoking the factory exactly once
|
||||
// on first resolution. Registering the Func<> itself (the original behavior)
|
||||
// left nothing in the container that satisfies TokenService's SigningKeyStore
|
||||
// dependency, so resolution of ITokenService would fail at runtime.
|
||||
services.AddSingleton(sp => keyStoreFactory(sp));
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a process-local <see cref="IReplayCache"/> (backed by
|
||||
/// <see cref="InMemoryReplayCache"/>) for detecting replayed one-time-use
|
||||
/// tokens. For multi-instance deployments, register your own <see cref="IReplayCache"/>
|
||||
/// implementation backed by a shared store instead of calling this method.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddSecureTokenReplayProtection(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IReplayCache, InMemoryReplayCache>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@ namespace EonaCat.SecureToken.Extensions
|
||||
/// <summary>HttpContext.Items key where validated claims are stored.</summary>
|
||||
public const string ClaimsItemKey = "SecureToken.Claims";
|
||||
|
||||
public SecureTokenMiddleware(
|
||||
RequestDelegate next,
|
||||
TokenValidationOptions options,
|
||||
ILogger<SecureTokenMiddleware> logger)
|
||||
public SecureTokenMiddleware(RequestDelegate next, TokenValidationOptions options, ILogger<SecureTokenMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_options = options;
|
||||
@@ -38,13 +35,10 @@ namespace EonaCat.SecureToken.Extensions
|
||||
|
||||
public async Task InvokeAsync(HttpContext context, ITokenService tokenService)
|
||||
{
|
||||
var authHeader = context.Request.Headers[HeaderNames.Authorization]
|
||||
.FirstOrDefault();
|
||||
|
||||
var authHeader = context.Request.Headers[HeaderNames.Authorization].FirstOrDefault();
|
||||
if (authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
|
||||
var result = await tokenService.ValidateAsync(token, _options, context.RequestAborted);
|
||||
|
||||
if (result.IsSuccess)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EonaCat.SecureToken.Validation
|
||||
{
|
||||
// This file is part of the EonaCat project(s) which is released under the Apache License.
|
||||
// See the LICENSE file or go to https://EonaCat.com/License for full license details.
|
||||
|
||||
/// <summary>
|
||||
/// Tracks token IDs (<c>jti</c>) that have already been consumed, so a token that is
|
||||
/// meant to be used exactly once (password reset, email verification, invitation,
|
||||
/// single-use refresh rotation) cannot be replayed even if an attacker captures it
|
||||
/// before its expiry.
|
||||
///
|
||||
/// Unlike <see cref="TokenValidationOptions.RevocationCheck"/> (which answers "has someone
|
||||
/// explicitly revoked this?"), a replay cache answers "has this exact token already been
|
||||
/// consumed once?" and records that fact atomically as part of the check.
|
||||
/// </summary>
|
||||
public interface IReplayCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Atomically checks whether <paramref name="tokenId"/> has been seen before and,
|
||||
/// if not, records it as seen. Returns <c>true</c> if this is the first time the
|
||||
/// token has been presented (i.e. it is safe to honor), or <c>false</c> if it has
|
||||
/// already been consumed (i.e. this is a replay).
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token's <c>jti</c>.</param>
|
||||
/// <param name="expiresAt">
|
||||
/// The token's expiry. Implementations may use this to bound how long the jti needs
|
||||
/// to be remembered for - there is no value in tracking a jti past the point where
|
||||
/// the token would be rejected for expiry anyway.
|
||||
/// </param>
|
||||
Task<bool> TryConsumeAsync(string tokenId, DateTimeOffset expiresAt, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simple process-local <see cref="IReplayCache"/> backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/>.
|
||||
///
|
||||
/// Suitable for single-instance deployments or as a reference implementation. Multi-instance
|
||||
/// deployments should back this interface with a shared store (Redis, a database table with
|
||||
/// a unique constraint on jti, etc.) so replay detection works across all instances.
|
||||
///
|
||||
/// Expired entries are swept lazily on each call so memory does not grow unbounded; for
|
||||
/// high-traffic services, call <see cref="Sweep"/> periodically from a background timer instead.
|
||||
/// </summary>
|
||||
public sealed class InMemoryReplayCache : IReplayCache
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DateTimeOffset> _seen = new ConcurrentDictionary<string, DateTimeOffset>();
|
||||
private long _opsSinceSweep;
|
||||
|
||||
/// <summary>Sweep the cache after this many operations, amortizing cleanup cost.</summary>
|
||||
private const long SweepInterval = 256;
|
||||
|
||||
public Task<bool> TryConsumeAsync(string tokenId, DateTimeOffset expiresAt, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tokenId))
|
||||
{
|
||||
// No identifier to track - fail closed by treating it as already consumed
|
||||
// rather than silently skipping replay protection.
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var firstUse = _seen.TryAdd(tokenId, expiresAt);
|
||||
|
||||
if (Interlocked.Increment(ref _opsSinceSweep) >= SweepInterval)
|
||||
{
|
||||
Interlocked.Exchange(ref _opsSinceSweep, 0);
|
||||
Sweep();
|
||||
}
|
||||
|
||||
return Task.FromResult(firstUse);
|
||||
}
|
||||
|
||||
/// <summary>Removes entries for tokens that have already expired.</summary>
|
||||
public void Sweep()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var kv in _seen)
|
||||
{
|
||||
if (kv.Value <= now)
|
||||
{
|
||||
_seen.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of token IDs currently tracked. Exposed for diagnostics/tests.</summary>
|
||||
public int Count => _seen.Count;
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,7 @@ namespace EonaCat.SecureToken.Cryptography
|
||||
ThrowIfDisposed();
|
||||
if (newKeyMaterial != null && newKeyMaterial.Length < MinimumKeyBytes)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Key material must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
|
||||
throw new ArgumentException($"Key material must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
|
||||
}
|
||||
|
||||
var material = newKeyMaterial ?? GenerateKey(MinimumKeyBytes);
|
||||
|
||||
@@ -228,8 +228,7 @@ namespace EonaCat.SecureToken.Core
|
||||
|
||||
if (value.Length > MaxStringLength)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"'{paramName}' must not exceed {MaxStringLength} characters.", paramName);
|
||||
throw new ArgumentException($"'{paramName}' must not exceed {MaxStringLength} characters.", paramName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,16 +36,9 @@ namespace EonaCat.SecureToken
|
||||
/// Issues a standard access + refresh token pair.
|
||||
/// Access token is short-lived; refresh token is long-lived but type-tagged.
|
||||
/// </summary>
|
||||
public static TokenPair IssueTokenPair(
|
||||
this ITokenService service,
|
||||
string subject,
|
||||
string issuer,
|
||||
string audience,
|
||||
IEnumerable<string>? roles = null,
|
||||
IEnumerable<KeyValuePair<string, string>>? claims = null,
|
||||
TimeSpan? accessTokenLifetime = null,
|
||||
TimeSpan? refreshTokenLifetime = null,
|
||||
string? bindingContext = null)
|
||||
public static TokenPair IssueTokenPair(this ITokenService service, string subject, string issuer, string audience,
|
||||
IEnumerable<string>? roles = null, IEnumerable<KeyValuePair<string, string>>? claims = null,
|
||||
TimeSpan? accessTokenLifetime = null, TimeSpan? refreshTokenLifetime = null, string? bindingContext = null)
|
||||
{
|
||||
var accessLifetime = accessTokenLifetime ?? TimeSpan.FromMinutes(15);
|
||||
var refreshLifetime = refreshTokenLifetime ?? TimeSpan.FromDays(30);
|
||||
@@ -91,9 +84,7 @@ namespace EonaCat.SecureToken
|
||||
var accessToken = service.Issue(accessDescriptor);
|
||||
var refreshToken = service.Issue(refreshDescriptor);
|
||||
|
||||
return new TokenPair(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
return new TokenPair(accessToken, refreshToken,
|
||||
now + accessLifetime,
|
||||
now + refreshLifetime
|
||||
);
|
||||
|
||||
@@ -38,6 +38,19 @@ namespace EonaCat.SecureToken.Core
|
||||
public override string ToString() => $"Expired(at={ExpiredAt:u})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token's age (now - iat) exceeds <see cref="EonaCat.SecureToken.Validation.TokenValidationOptions.MaxTokenAge"/>.
|
||||
/// This is a defense-in-depth backstop distinct from <see cref="Expired"/>: it fires
|
||||
/// even if the token's own <c>exp</c> claim would still consider it valid.
|
||||
/// </summary>
|
||||
public sealed class TooOld : TokenResult
|
||||
{
|
||||
public DateTimeOffset IssuedAt { get; }
|
||||
public TimeSpan MaxAge { get; }
|
||||
public TooOld(DateTimeOffset issuedAt, TimeSpan maxAge) { IssuedAt = issuedAt; MaxAge = maxAge; }
|
||||
public override string ToString() => $"TooOld(iat={IssuedAt:u}, maxAge={MaxAge})";
|
||||
}
|
||||
|
||||
/// <summary>HMAC signature does not match the payload.</summary>
|
||||
public sealed class InvalidSignature : TokenResult
|
||||
{
|
||||
@@ -107,6 +120,19 @@ namespace EonaCat.SecureToken.Core
|
||||
public override string ToString() => $"Revoked(jti={TokenId})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Token has already been consumed once before, per the configured
|
||||
/// <see cref="EonaCat.SecureToken.Validation.IReplayCache"/>. Indicates either a genuine
|
||||
/// replay attack or a client retrying with a one-time-use token it should have discarded
|
||||
/// after the first successful use.
|
||||
/// </summary>
|
||||
public sealed class Replayed : TokenResult
|
||||
{
|
||||
public string TokenId { get; }
|
||||
public Replayed(string tokenId) { TokenId = tokenId; }
|
||||
public override string ToString() => $"Replayed(jti={TokenId})";
|
||||
}
|
||||
|
||||
/// <summary>Token string is structurally invalid (wrong prefix, bad Base64, corrupt payload).</summary>
|
||||
public sealed class Malformed : TokenResult
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using EonaCat.SecureToken.Tokens;
|
||||
using EonaCat.SecureToken.Validation;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -23,7 +24,11 @@ namespace EonaCat.SecureToken
|
||||
/// <summary>Validates a token string and returns a typed result.</summary>
|
||||
Task<TokenResult> ValidateAsync(string token, TokenValidationOptions options, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Validates synchronously (no revocation check).</summary>
|
||||
/// <summary>
|
||||
/// Validates synchronously. Does not perform <see cref="TokenValidationOptions.RevocationCheck"/>
|
||||
/// or <see cref="TokenValidationOptions.ReplayCache"/> consumption, since both are inherently
|
||||
/// async (and the replay check has a side effect) - use <see cref="ValidateAsync"/> for those.
|
||||
/// </summary>
|
||||
TokenResult Validate(string token, TokenValidationOptions options);
|
||||
|
||||
/// <summary>
|
||||
@@ -71,12 +76,29 @@ namespace EonaCat.SecureToken
|
||||
{
|
||||
var result = Validate(token, options);
|
||||
|
||||
if (result is TokenResult.Success success && options.RevocationCheck != null)
|
||||
if (result is TokenResult.Success success)
|
||||
{
|
||||
var isRevoked = await options.RevocationCheck(success.Claims.TokenId, ct);
|
||||
if (isRevoked)
|
||||
if (options.RevocationCheck != null)
|
||||
{
|
||||
return new TokenResult.Revoked(success.Claims.TokenId);
|
||||
var isRevoked = await options.RevocationCheck(success.Claims.TokenId, ct);
|
||||
if (isRevoked)
|
||||
{
|
||||
result = new TokenResult.Revoked(success.Claims.TokenId);
|
||||
EmitValidatedEvent(options, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.ReplayCache != null)
|
||||
{
|
||||
var firstUse = await options.ReplayCache.TryConsumeAsync(
|
||||
success.Claims.TokenId, success.Claims.ExpiresAt, ct);
|
||||
if (!firstUse)
|
||||
{
|
||||
result = new TokenResult.Replayed(success.Claims.TokenId);
|
||||
EmitValidatedEvent(options, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +107,13 @@ namespace EonaCat.SecureToken
|
||||
|
||||
/// <inheritdoc />
|
||||
public TokenResult Validate(string token, TokenValidationOptions options)
|
||||
{
|
||||
var result = ValidateInternal(token, options);
|
||||
EmitValidatedEvent(options, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private TokenResult ValidateInternal(string token, TokenValidationOptions options)
|
||||
{
|
||||
if (!TokenSerializer.TryDecode(token, out var payload, out var signature))
|
||||
{
|
||||
@@ -134,19 +163,42 @@ namespace EonaCat.SecureToken
|
||||
return new TokenResult.NotYetValid(claims.NotBefore);
|
||||
}
|
||||
|
||||
// Maximum-age backstop: catches tokens whose exp claim was set unusually far
|
||||
// ahead of iat, independent of whatever ExpiresAt itself says.
|
||||
if (options.MaxTokenAge.HasValue)
|
||||
{
|
||||
var age = now - claims.IssuedAt;
|
||||
if (age > options.MaxTokenAge.Value + skew)
|
||||
{
|
||||
return new TokenResult.TooOld(claims.IssuedAt, options.MaxTokenAge.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Issuer check
|
||||
if (options.ValidIssuer != null && claims.Issuer != options.ValidIssuer)
|
||||
{
|
||||
return new TokenResult.UntrustedIssuer(claims.Issuer);
|
||||
}
|
||||
|
||||
// Audience check
|
||||
if (options.ValidAudience != null && !claims.Audiences.Contains(options.ValidAudience))
|
||||
// Audience check. ValidAudiences (an accept-list) takes precedence over the
|
||||
// single ValidAudience when both are set; the token passes if any of its own
|
||||
// audiences intersects with the configured set.
|
||||
if (options.ValidAudiences != null)
|
||||
{
|
||||
if (!claims.Audiences.Any(a => options.ValidAudiences.Contains(a)))
|
||||
{
|
||||
return new TokenResult.WrongAudience(
|
||||
string.Join(",", options.ValidAudiences), claims.Audiences);
|
||||
}
|
||||
}
|
||||
else if (options.ValidAudience != null && !claims.Audiences.Contains(options.ValidAudience))
|
||||
{
|
||||
return new TokenResult.WrongAudience(options.ValidAudience, claims.Audiences);
|
||||
}
|
||||
|
||||
// Binding check
|
||||
// Binding check. Compared in constant time: the binding context is often derived
|
||||
// from sensitive context (device fingerprint, session identifier) and a variable-time
|
||||
// comparison would leak how many leading characters an attacker's guess got right.
|
||||
if (options.BindingContext != null)
|
||||
{
|
||||
if (claims.BindingContext is null)
|
||||
@@ -154,7 +206,7 @@ namespace EonaCat.SecureToken
|
||||
return new TokenResult.BindingMismatch("Token has no binding context.");
|
||||
}
|
||||
|
||||
if (!string.Equals(claims.BindingContext, options.BindingContext, StringComparison.Ordinal))
|
||||
if (!FixedTimeStringEquals(claims.BindingContext, options.BindingContext))
|
||||
{
|
||||
return new TokenResult.BindingMismatch("Binding context mismatch.");
|
||||
}
|
||||
@@ -163,6 +215,39 @@ namespace EonaCat.SecureToken
|
||||
return new TokenResult.Success(token, claims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant-time comparison for UTF-8 string content, mirroring the byte-array
|
||||
/// comparison already used for signature verification. Falls back to comparing
|
||||
/// against a fixed-size buffer when lengths differ, so the comparison time does
|
||||
/// not vary based on how much of a guess is correct OR on the guess's length.
|
||||
/// </summary>
|
||||
private static bool FixedTimeStringEquals(string a, string b)
|
||||
{
|
||||
var bytesA = Encoding.UTF8.GetBytes(a);
|
||||
var bytesB = Encoding.UTF8.GetBytes(b);
|
||||
return CryptographicOperations.FixedTimeEquals(bytesA, bytesB);
|
||||
}
|
||||
|
||||
private static void EmitValidatedEvent(TokenValidationOptions options, TokenResult result)
|
||||
{
|
||||
var callback = options.OnValidated;
|
||||
if (callback is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? subject = null;
|
||||
string? tokenId = null;
|
||||
|
||||
if (result is TokenResult.Success success)
|
||||
{
|
||||
subject = success.Claims.Subject;
|
||||
tokenId = success.Claims.TokenId;
|
||||
}
|
||||
|
||||
callback(new TokenValidationEvent(result, subject, tokenId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TokenClaims? Inspect(string token)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using EonaCat.SecureToken.Core;
|
||||
@@ -22,9 +23,19 @@ namespace EonaCat.SecureToken.Validation
|
||||
|
||||
/// <summary>
|
||||
/// Expected audience (<c>aud</c>). Null disables audience validation - not recommended.
|
||||
/// If <see cref="ValidAudiences"/> is also set, that takes precedence and this is ignored.
|
||||
/// </summary>
|
||||
public string? ValidAudience { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Accept-list of audiences. The token is valid if any one of its own audiences
|
||||
/// matches any one of these. Use this instead of <see cref="ValidAudience"/> when the
|
||||
/// same access token must be accepted by more than one downstream service (e.g. a
|
||||
/// gateway that fans out to several internal APIs sharing one audience pool).
|
||||
/// When set, this takes precedence over <see cref="ValidAudience"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string>? ValidAudiences { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required token-type tag. Null disables type validation - not recommended outside
|
||||
/// of special diagnostic scenarios.
|
||||
@@ -66,6 +77,24 @@ namespace EonaCat.SecureToken.Validation
|
||||
/// </summary>
|
||||
public TimeSpan? MaxTokenAge { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional replay cache. When set, <see cref="ITokenService.ValidateAsync"/> will mark the
|
||||
/// token's <c>jti</c> as consumed and reject the token (with <see cref="TokenResult.Replayed"/>)
|
||||
/// if it has already been presented before. Use this for one-time-use tokens such as
|
||||
/// password-reset or email-verification links. Not checked by the synchronous <see cref="ITokenService.Validate"/>
|
||||
/// overload, since consuming a single-use token is inherently a side effect and should go
|
||||
/// through the async path.
|
||||
/// </summary>
|
||||
public IReplayCache? ReplayCache { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback invoked with the outcome of every validation attempt, regardless of
|
||||
/// success or failure. Intended for audit logging, metrics, or SIEM integration - not for
|
||||
/// authorization decisions. Exceptions thrown by this callback are not caught; keep it fast
|
||||
/// and non-throwing, or wrap your own logic in a try/catch.
|
||||
/// </summary>
|
||||
public Action<TokenValidationEvent>? OnValidated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Strict access-token options: issuer, audience, type, and optional binding context.
|
||||
/// Suitable for most API endpoint authorization.
|
||||
@@ -82,6 +111,21 @@ namespace EonaCat.SecureToken.Validation
|
||||
BindingContext = bindingContext,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Access-token options accepting any one of several audiences. See <see cref="ValidAudiences"/>.
|
||||
/// </summary>
|
||||
public static TokenValidationOptions AccessToken(
|
||||
string issuer,
|
||||
IReadOnlyCollection<string> audiences,
|
||||
string? bindingContext = null) =>
|
||||
new TokenValidationOptions
|
||||
{
|
||||
ValidIssuer = issuer,
|
||||
ValidAudiences = audiences,
|
||||
RequiredTokenType = TokenTypeConstants.Access,
|
||||
BindingContext = bindingContext,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Refresh-token options. Audience is intentionally not required here because
|
||||
/// refresh tokens are typically consumed at the authorization server, not forwarded.
|
||||
@@ -109,4 +153,39 @@ namespace EonaCat.SecureToken.Validation
|
||||
MaxTokenAge = maxAge,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a single validation attempt, passed to <see cref="TokenValidationOptions.OnValidated"/>.
|
||||
/// Carries enough context for audit logging without forcing callers to re-derive it from the
|
||||
/// <see cref="TokenResult"/> shape.
|
||||
/// </summary>
|
||||
public sealed class TokenValidationEvent
|
||||
{
|
||||
/// <summary>The validation outcome.</summary>
|
||||
public TokenResult Result { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The subject of the token. Only populated when <see cref="Result"/> is a
|
||||
/// <see cref="TokenResult.Success"/>, since claims are not trusted until the
|
||||
/// signature has verified - earlier failure paths (e.g. <see cref="TokenResult.InvalidSignature"/>)
|
||||
/// intentionally leave this null rather than surface unverified data.
|
||||
/// </summary>
|
||||
public string? Subject { get; }
|
||||
|
||||
/// <summary>The token ID (<c>jti</c>). Only populated on success, for the same reason as <see cref="Subject"/>.</summary>
|
||||
public string? TokenId { get; }
|
||||
|
||||
/// <summary>UTC instant the validation attempt occurred.</summary>
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public TokenValidationEvent(TokenResult result, string? subject, string? tokenId)
|
||||
{
|
||||
Result = result;
|
||||
Subject = subject;
|
||||
TokenId = tokenId;
|
||||
Timestamp = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public override string ToString() =>$"TokenValidationEvent(sub={Subject ?? "?"}, jti={TokenId ?? "?"}, result={Result})";
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,15 @@ namespace EonaCat.SecureToken.Tokens
|
||||
private const byte TagKeyGeneration = 0x22;
|
||||
private const byte TagBindingContext = 0x30;
|
||||
|
||||
// Defense-in-depth caps. These are deliberately generous relative to anything
|
||||
// TokenDescriptor would ever produce (its own limits are much tighter), so they
|
||||
// never reject a legitimately-issued token. They exist purely to bound the cost
|
||||
// of decoding an attacker-supplied string before signature verification has run.
|
||||
private const int MaxTokenStringLength = 16 * 1024; // raw "stv1.x.y" string
|
||||
private const int MaxPayloadBytes = 64 * 1024; // decoded payload buffer
|
||||
private const int MaxFieldCount = 512; // total tagged fields
|
||||
private const int MaxFieldStringLength = 8192; // bytes per individual string field
|
||||
|
||||
public static byte[] Serialize(TokenClaims claims)
|
||||
{
|
||||
using var ms = new System.IO.MemoryStream();
|
||||
@@ -83,6 +92,11 @@ namespace EonaCat.SecureToken.Tokens
|
||||
|
||||
public static TokenClaims? Deserialize(byte[] data)
|
||||
{
|
||||
if (data is null || data.Length == 0 || data.Length > MaxPayloadBytes)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var ms = new System.IO.MemoryStream(data);
|
||||
@@ -95,9 +109,17 @@ namespace EonaCat.SecureToken.Tokens
|
||||
var audiences = new List<string>();
|
||||
var roles = new List<string>();
|
||||
var custom = new Dictionary<string, string>();
|
||||
var fieldCount = 0;
|
||||
|
||||
while (ms.Position < ms.Length)
|
||||
{
|
||||
if (++fieldCount > MaxFieldCount)
|
||||
{
|
||||
// A legitimately-issued token never carries this many fields;
|
||||
// treat it as malformed rather than keep allocating.
|
||||
return null;
|
||||
}
|
||||
|
||||
var tag = br.ReadByte();
|
||||
switch (tag)
|
||||
{
|
||||
@@ -155,6 +177,11 @@ namespace EonaCat.SecureToken.Tokens
|
||||
payload = new byte[0];
|
||||
signature = new byte[0];
|
||||
|
||||
if (string.IsNullOrEmpty(token) || token.Length > MaxTokenStringLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3 || parts[0] != Prefix)
|
||||
{
|
||||
@@ -164,6 +191,11 @@ namespace EonaCat.SecureToken.Tokens
|
||||
try
|
||||
{
|
||||
payload = Base64UrlDecode(parts[1]);
|
||||
if (payload.Length > MaxPayloadBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
signature = Base64UrlDecode(parts[2]);
|
||||
return true;
|
||||
}
|
||||
@@ -189,7 +221,20 @@ namespace EonaCat.SecureToken.Tokens
|
||||
private static string ReadRawString(System.IO.BinaryReader br)
|
||||
{
|
||||
var len = br.ReadUInt16();
|
||||
if (len > MaxFieldStringLength)
|
||||
{
|
||||
// Caught by Deserialize's surrounding try/catch and reported as malformed.
|
||||
throw new FormatException("Field exceeds maximum allowed length.");
|
||||
}
|
||||
|
||||
var bytes = br.ReadBytes(len);
|
||||
if (bytes.Length != len)
|
||||
{
|
||||
// BinaryReader.ReadBytes returns fewer bytes than requested when the
|
||||
// stream is truncated, instead of throwing. Treat that as malformed.
|
||||
throw new System.IO.EndOfStreamException();
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,20 @@ EonaCat.SecureToken provides a safer alternative to rolling your own authenticat
|
||||
|
||||
- HMAC based token signing
|
||||
- HKDF derived context-specific keys
|
||||
- Constant-time signature verification
|
||||
- Constant-time signature verification (including binding-context comparison)
|
||||
- Tamper detection
|
||||
- Strong random key generation
|
||||
- Bounded decoding: oversized or field-flooded tokens are rejected before signature
|
||||
verification, so malformed input cannot be used to exhaust memory
|
||||
|
||||
### Validation
|
||||
|
||||
- Issuer, audience (single or multi-audience accept-list), and token-type checks
|
||||
- Expiry, not-before, and an independent `MaxTokenAge` backstop measured from issuance
|
||||
- Token binding (IP, device fingerprint, TLS channel hash, etc.)
|
||||
- Pluggable revocation check
|
||||
- Pluggable replay-cache for one-time-use tokens (password reset, email verification, invitations)
|
||||
- `OnValidated` audit hook fired for every validation attempt
|
||||
|
||||
### Key rotation
|
||||
|
||||
@@ -205,6 +216,64 @@ var options = new TokenValidationOptions
|
||||
};
|
||||
```
|
||||
|
||||
# Replay protection for one-time-use tokens
|
||||
|
||||
Revocation answers "has someone explicitly blocked this token?" Replay protection
|
||||
answers a different question: "has this exact token already been used once?" Use it
|
||||
for password-reset links, email-verification links, and invitations - anything that
|
||||
should only ever be redeemed a single time, even before it expires.
|
||||
|
||||
```csharp
|
||||
var replayCache = new InMemoryReplayCache(); // register as a singleton in DI
|
||||
|
||||
var options = TokenValidationOptions.OneTimeUse(
|
||||
issuer: "api",
|
||||
tokenType: TokenTypeConstants.PasswordReset,
|
||||
maxAge: TimeSpan.FromMinutes(15));
|
||||
|
||||
options.ReplayCache = replayCache;
|
||||
|
||||
var result = await tokens.ValidateAsync(token, options);
|
||||
// Second call with the same token returns TokenResult.Replayed instead of Success.
|
||||
```
|
||||
|
||||
`InMemoryReplayCache` is process-local and fine for a single instance. For multi-instance
|
||||
deployments, implement `IReplayCache` against a shared store (Redis, or a database table
|
||||
with a unique constraint on the token ID) so replay detection works across all instances.
|
||||
|
||||
Replay consumption only happens through `ValidateAsync`, since it has a side effect
|
||||
(recording the token as used) - the synchronous `Validate` never touches the replay cache.
|
||||
|
||||
# Multiple audiences
|
||||
|
||||
Use `ValidAudiences` when the same access token needs to be accepted by more than one
|
||||
downstream service:
|
||||
|
||||
```csharp
|
||||
var options = TokenValidationOptions.AccessToken(
|
||||
issuer: "api",
|
||||
audiences: new[] { "service-a", "service-b", "service-c" });
|
||||
```
|
||||
|
||||
The token is accepted if any one of its own audiences matches any one of the configured set.
|
||||
|
||||
# Audit and introspection hook
|
||||
|
||||
`OnValidated` is invoked for every validation attempt, success or failure, and is intended
|
||||
for audit logging or metrics - not for authorization decisions:
|
||||
|
||||
```csharp
|
||||
var options = TokenValidationOptions.AccessToken("api", "web");
|
||||
|
||||
options.OnValidated = e =>
|
||||
{
|
||||
logger.LogInformation("Token validation: {Result} sub={Subject}", e.Result, e.Subject);
|
||||
};
|
||||
```
|
||||
|
||||
`Subject` and `TokenId` on the event are only populated when the result is a success,
|
||||
since claims are never trusted before the signature has verified.
|
||||
|
||||
# ASP.NET Core dependency injection
|
||||
|
||||
```csharp
|
||||
@@ -225,6 +294,13 @@ builder.Services.AddSecureTokens(
|
||||
});
|
||||
```
|
||||
|
||||
To enable replay protection via DI:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSecureTokenReplayProtection();
|
||||
```
|
||||
|
||||
|
||||
# Security design
|
||||
|
||||
The library separates cryptographic purposes:
|
||||
|
||||
Reference in New Issue
Block a user