Added .net standard 2.0 and .net framework 4.8 support

This commit is contained in:
2026-06-19 20:17:05 +02:00
parent 038eb5d225
commit 8b2a776da4
13 changed files with 845 additions and 438 deletions
+9 -14
View File
@@ -1,18 +1,17 @@
using FluentAssertions; using FluentAssertions;
using SecureToken.Core; using EonaCat.SecureToken.Core;
using SecureToken.Cryptography; using EonaCat.SecureToken.Cryptography;
using SecureToken.Validation; using EonaCat.SecureToken.Validation;
using Xunit; using Xunit;
namespace SecureToken.Tests namespace EonaCat.SecureToken.Tests
{ {
public sealed class TokenServiceTests public sealed class TokenServiceTests
{ {
private static ITokenService CreateService(SigningKeyStore? store = null) => private static ITokenService CreateService(SigningKeyStore? store = null) =>
new TokenService(store ?? SigningKeyStore.CreateNew()); new TokenService(store ?? SigningKeyStore.CreateNew());
// ── Issue & Validate ───────────────────────────────────────────────────── // Issue & Validate
[Fact] [Fact]
public void Issue_And_Validate_ReturnsSuccess() public void Issue_And_Validate_ReturnsSuccess()
{ {
@@ -142,8 +141,7 @@ namespace SecureToken.Tests
result.IsSuccess.Should().BeTrue(); result.IsSuccess.Should().BeTrue();
} }
// ── Key Rotation ───────────────────────────────────────────────────────── // Key Rotation
[Fact] [Fact]
public void KeyRotation_OldTokensRemainValid() public void KeyRotation_OldTokensRemainValid()
{ {
@@ -186,8 +184,7 @@ namespace SecureToken.Tests
result.IsSuccess.Should().BeTrue(); result.IsSuccess.Should().BeTrue();
} }
// ── Token Pair ─────────────────────────────────────────────────────────── // Token Pair
[Fact] [Fact]
public void IssueTokenPair_ProducesValidPair() public void IssueTokenPair_ProducesValidPair()
{ {
@@ -213,8 +210,7 @@ namespace SecureToken.Tests
result.Should().BeOfType<TokenResult.WrongTokenType>(); result.Should().BeOfType<TokenResult.WrongTokenType>();
} }
// ── Revocation ─────────────────────────────────────────────────────────── // Revocation
[Fact] [Fact]
public async Task RevocationCheck_RevokedToken_ReturnsRevoked() public async Task RevocationCheck_RevokedToken_ReturnsRevoked()
{ {
@@ -237,8 +233,7 @@ namespace SecureToken.Tests
result.Should().BeOfType<TokenResult.Revoked>(); result.Should().BeOfType<TokenResult.Revoked>();
} }
// ── Inspect (no signature check) ───────────────────────────────────────── // Inspect (no signature check)
[Fact] [Fact]
public void Inspect_MalformedToken_ReturnsNull() public void Inspect_MalformedToken_ReturnsNull()
{ {
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>netstandard2.0;net4.8;net8.0</TargetFrameworks>
<ApplicationIcon>icon.ico</ApplicationIcon> <ApplicationIcon>icon.ico</ApplicationIcon>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Authors>EonaCat (Jeroen Saey)</Authors> <Authors>EonaCat (Jeroen Saey)</Authors>
@@ -8,13 +8,13 @@
<Company>EonaCat (Jeroen Saey)</Company> <Company>EonaCat (Jeroen Saey)</Company>
<PackageIcon>icon.png</PackageIcon> <PackageIcon>icon.png</PackageIcon>
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.SecureToken/</PackageProjectUrl> <PackageProjectUrl>https://www.nuget.org/packages/EonaCat.SecureToken/</PackageProjectUrl>
<Description>A modern, cryptographically superior alternative to JWT. SecureToken, <Description>EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens.
structured binding, built-in rotation, and opaque reference tokens with zero parsing vulnerabilities.</Description> Secure, modern token library for .NET with key rotation, signing isolation and validation rules</Description>
<PackageReleaseNotes>Public release version</PackageReleaseNotes> <PackageReleaseNotes>Public release version</PackageReleaseNotes>
<Copyright>EonaCat (Jeroen Saey)</Copyright> <Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageTags>EonaCat;authentication;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags> <PackageTags>EonaCat;authentication;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
<PackageIconUrl /> <PackageIconUrl />
<FileVersion>0.1.1</FileVersion> <FileVersion>0.0.2</FileVersion>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile> <GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile> <PackageLicenseFile>LICENSE</PackageLicenseFile>
@@ -25,7 +25,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<EVRevisionFormat>0.0.1+{chash:10}.{c:ymd}</EVRevisionFormat> <EVRevisionFormat>0.0.2+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVDefault>true</EVDefault> <EVDefault>true</EVDefault>
<EVInfo>true</EVInfo> <EVInfo>true</EVInfo>
<EVTagMatch>v[0-9]*</EVTagMatch> <EVTagMatch>v[0-9]*</EVTagMatch>
@@ -36,7 +36,7 @@
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<Version>0.0.1</Version> <Version>0.0.2</Version>
<PackageId>EonaCat.SecureToken</PackageId> <PackageId>EonaCat.SecureToken</PackageId>
<Product>EonaCat.SecureToken</Product> <Product>EonaCat.SecureToken</Product>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl> <RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
@@ -73,8 +73,8 @@
</PackageReference> </PackageReference>
<PackageReference Include="EonaCat.Versioning.Helpers" Version="1.5.1" /> <PackageReference Include="EonaCat.Versioning.Helpers" Version="1.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.3.11" /> <PackageReference Include="Microsoft.Net.Http.Headers" Version="2.3.11" />
<PackageReference Include="System.Memory" Version="4.5.5" /> <PackageReference Include="System.Memory" Version="4.5.5" />
</ItemGroup> </ItemGroup>
@@ -1,9 +1,8 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SecureToken.Cryptography; using EonaCat.SecureToken.Cryptography;
using System; using System;
namespace EonaCat.SecureToken.Extensions
namespace SecureToken.Extensions
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
@@ -1,12 +1,12 @@
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using SecureToken.Validation; using EonaCat.SecureToken.Validation;
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace SecureToken.Extensions namespace EonaCat.SecureToken.Extensions
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
+227 -36
View File
@@ -1,8 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text;
using System.Threading;
namespace SecureToken.Cryptography namespace EonaCat.SecureToken.Cryptography
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
@@ -10,31 +13,45 @@ namespace SecureToken.Cryptography
/// <summary> /// <summary>
/// Manages a versioned set of signing keys, enabling seamless key rotation. /// Manages a versioned set of signing keys, enabling seamless key rotation.
/// Old tokens remain verifiable while new tokens always use the latest key. /// Old tokens remain verifiable while new tokens always use the latest key.
/// Thread-safe: all reads and writes are protected by a ReaderWriterLockSlim.
/// </summary> /// </summary>
public sealed class SigningKeyStore public sealed class SigningKeyStore : IDisposable
{ {
private readonly SortedDictionary<int, byte[]> _keys = new SortedDictionary<int, byte[]>(); private readonly SortedDictionary<int, byte[]> _keys = new SortedDictionary<int, byte[]>();
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
private int _currentGeneration; private int _currentGeneration;
private bool _disposed;
/// <summary>Minimum key size in bytes (64 = 512 bits for HMAC-SHA512).</summary>
public const int MinimumKeyBytes = 64;
private SigningKeyStore() { } private SigningKeyStore() { }
/// <summary>Creates a new key store with a single randomly generated key.</summary> /// <summary>Creates a new key store with a single cryptographically-random key.</summary>
public static SigningKeyStore CreateNew() public static SigningKeyStore CreateNew()
{ {
var store = new SigningKeyStore(); var store = new SigningKeyStore();
store.AddNewKey(RandomNumberGenerator.GetBytes(64)); store.AddNewKey(GenerateKey(MinimumKeyBytes));
return store; return store;
} }
/// <summary>Creates a key store from an existing set of versioned keys.</summary> /// <summary>
/// Creates a key store from an existing set of versioned keys.
/// Throws if any key is shorter than <see cref="MinimumKeyBytes"/>.
/// </summary>
public static SigningKeyStore FromKeys(IEnumerable<(int generation, byte[] keyMaterial)> keys) public static SigningKeyStore FromKeys(IEnumerable<(int generation, byte[] keyMaterial)> keys)
{ {
if (keys is null)
{
throw new ArgumentNullException(nameof(keys));
}
var store = new SigningKeyStore(); var store = new SigningKeyStore();
foreach (var (gen, key) in keys) foreach (var (gen, key) in keys)
{ {
if (key.Length < 32) if (key is null || key.Length < MinimumKeyBytes)
{ {
throw new ArgumentException($"Key generation {gen} must be at least 32 bytes."); throw new ArgumentException($"Key generation {gen} must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
} }
store._keys[gen] = key; store._keys[gen] = key;
@@ -43,75 +60,249 @@ namespace SecureToken.Cryptography
store._currentGeneration = gen; store._currentGeneration = gen;
} }
} }
if (store._keys.Count == 0)
{
throw new ArgumentException("At least one key must be supplied.");
}
return store; return store;
} }
/// <summary> /// <summary>
/// Rotates to a new key. Old tokens remain verifiable until they expire. /// Rotates to a new key. Old tokens remain verifiable until they expire.
/// New tokens are signed with the new key. /// Returns the new generation number.
/// </summary> /// </summary>
public int Rotate(byte[]? newKeyMaterial = null) public int Rotate(byte[]? newKeyMaterial = null)
{ {
var material = newKeyMaterial ?? RandomNumberGenerator.GetBytes(64); ThrowIfDisposed();
if (newKeyMaterial != null && newKeyMaterial.Length < MinimumKeyBytes)
{
throw new ArgumentException(
$"Key material must be at least {MinimumKeyBytes} bytes ({MinimumKeyBytes * 8} bits).");
}
var material = newKeyMaterial ?? GenerateKey(MinimumKeyBytes);
return AddNewKey(material); return AddNewKey(material);
} }
/// <summary>Removes expired key generations to free memory. Only remove if all tokens from that generation are expired.</summary> /// <summary>
public void PruneGeneration(int generation) => _keys.Remove(generation); /// Removes an old key generation from memory.
/// Only call this once you are certain all tokens signed with that generation are expired.
/// Refuses to remove the current generation.
/// </summary>
public void PruneGeneration(int generation)
{
ThrowIfDisposed();
_lock.EnterWriteLock();
try
{
if (generation == _currentGeneration)
{
throw new InvalidOperationException("Cannot prune the current key generation.");
}
public int CurrentGeneration => _currentGeneration; _keys.TryGetValue(generation, out var key);
if (key != null)
{
Array.Clear(key, 0, key.Length);
}
internal byte[] GetCurrentKey() => _keys[_currentGeneration]; _keys.Remove(generation);
}
finally { _lock.ExitWriteLock(); }
}
/// <summary>The generation number of the key that will be used to sign new tokens.</summary>
public int CurrentGeneration
{
get
{
_lock.EnterReadLock();
try { return _currentGeneration; }
finally { _lock.ExitReadLock(); }
}
}
internal bool TryGetKey(int generation, out byte[] key) internal bool TryGetKey(int generation, out byte[] key)
{ {
if (_keys.TryGetValue(generation, out var k)) _lock.EnterReadLock();
try
{ {
key = k; if (_keys.TryGetValue(generation, out var k))
return true; {
key = k;
return true;
}
key = Array.Empty<byte>();
return false;
}
finally { _lock.ExitReadLock(); }
}
/// <summary>Zeroes all signing material and releases the lock.</summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_lock.EnterWriteLock();
try
{
foreach (var key in _keys.Values)
{
Array.Clear(key, 0, key.Length);
}
_keys.Clear();
_disposed = true;
}
finally
{
_lock.ExitWriteLock();
_lock.Dispose();
} }
key = new byte[32];
return false;
} }
private int AddNewKey(byte[] material) private int AddNewKey(byte[] material)
{ {
var gen = _currentGeneration + 1; _lock.EnterWriteLock();
_keys[gen] = material; try
_currentGeneration = gen; {
return gen; var gen = _currentGeneration + 1;
_keys[gen] = material;
_currentGeneration = gen;
return gen;
}
finally { _lock.ExitWriteLock(); }
}
private static byte[] GenerateKey(int size)
{
var key = new byte[size];
using (var rng = RandomNumberGenerator.Create())
{
rng.GetBytes(key);
}
return key;
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SigningKeyStore));
}
} }
} }
/// <summary> /// <summary>
/// Low-level HMAC-SHA256 signer. All signing is deterministic and uses a separate /// Low-level HMAC-SHA512 signer with HKDF-derived sub-keys.
/// per-field HKDF-derived sub-key to prevent cross-context attacks. /// Signing is deterministic; each token type gets its own isolated key.
/// </summary> /// </summary>
internal static class TokenSigner internal static class TokenSigner
{ {
private const string SigningInfo = "SecureToken-v1-Signing"; private const string SigningInfo = "EonaCat.SecureToken|v2|Signing";
private const string EncryptionInfo = "SecureToken-v1-Encryption";
/// <summary> /// <summary>
/// Derives a context-specific signing sub-key using HKDF to prevent /// Derives a context-specific signing sub-key using HKDF-SHA512 to prevent
/// the same key material being misused across different contexts. /// the same master key material from being usable across different token types.
/// </summary> /// </summary>
public static byte[] DeriveSigningKey(byte[] masterKey, string context) public static byte[] DeriveSigningKey(byte[] masterKey, string context)
{ {
// HKDF-Extract + HKDF-Expand // HKDF Extract: PRK = HMAC-SHA512(salt=masterKey, IKM=context)
var prk = HMACSHA256.HashData(masterKey, System.Text.Encoding.UTF8.GetBytes(context)); byte[] prk;
var info = System.Text.Encoding.UTF8.GetBytes(SigningInfo + "|" + context); using (var hmac = new HMACSHA512(masterKey))
return HKDF.Expand(HashAlgorithmName.SHA256, prk, 32, info); {
var input = Encoding.UTF8.GetBytes(context);
prk = hmac.ComputeHash(input);
}
// HKDF Expand with a domain-specific info string
var info = Encoding.UTF8.GetBytes(SigningInfo + "|" + context);
return HkdfExpand(prk, info, 64);
} }
public static byte[] Sign(byte[] signingKey, byte[] payload) => private static byte[] HkdfExpand(byte[] prk, byte[] info, int length)
HMACSHA256.HashData(signingKey, payload); {
using var hmac = new HMACSHA512(prk);
var output = new List<byte>(length + 64);
byte[] previous = Array.Empty<byte>();
byte counter = 1;
while (output.Count < length)
{
// T(i) = HMAC(PRK, T(i-1) || info || i)
var data = Combine(previous, Combine(info, new[] { counter }));
previous = hmac.ComputeHash(data);
output.AddRange(previous);
counter++;
}
var result = new byte[length];
output.CopyTo(0, result, 0, length);
return result;
}
public static byte[] Sign(byte[] signingKey, byte[] payload)
{
using var hmac = new HMACSHA512(signingKey);
return hmac.ComputeHash(payload);
}
/// <summary>
/// Verifies that <paramref name="signature"/> matches the HMAC of <paramref name="payload"/>.
/// Uses constant-time comparison to prevent timing side-channel attacks.
/// Also validates that the signature is the expected length before comparing,
/// to avoid length-extension style short-circuit paths.
/// </summary>
public static bool Verify(byte[] signingKey, byte[] payload, byte[] signature) public static bool Verify(byte[] signingKey, byte[] payload, byte[] signature)
{ {
if (payload is null || signature is null)
{
return false;
}
var expected = Sign(signingKey, payload); var expected = Sign(signingKey, payload);
// Constant-time comparison to prevent timing attacks
return CryptographicOperations.FixedTimeEquals(expected, signature); return CryptographicOperations.FixedTimeEquals(expected, signature);
} }
private static byte[] Combine(byte[] a, byte[] b)
{
var result = new byte[a.Length + b.Length];
Buffer.BlockCopy(a, 0, result, 0, a.Length);
Buffer.BlockCopy(b, 0, result, a.Length, b.Length);
return result;
}
} }
}
/// <summary>
/// Uses <see cref="System.Security.Cryptography.CryptographicOperations"/> for constant-time
/// byte comparison when available (.NET Core 2.1+), falling back gracefully otherwise.
/// </summary>
internal static class CryptographicOperations
{
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
public static bool FixedTimeEquals(byte[] left, byte[] right)
{
if (left is null || right is null)
{
return false;
}
if (left.Length != right.Length)
{
return false;
}
int diff = 0;
for (int i = 0; i < left.Length; i++)
{
diff |= left[i] ^ right[i];
}
return diff == 0;
}
}
}
+33 -23
View File
@@ -1,57 +1,60 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace SecureToken.Core namespace EonaCat.SecureToken.Core
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary> /// <summary>
/// Represents the claims payload of a SecureToken. /// Represents the verified claims payload of a SecureToken.
/// Strongly-typed and tamper-evident - no algorithm confusion possible. /// All fields are strongly-typed. No JSON or string-map ambiguity.
/// </summary> /// </summary>
public sealed class TokenClaims public sealed class TokenClaims
{ {
/// <summary>Unique token identifier (prevents replay attacks).</summary> /// <summary>Unique token identifier. Used to detect replayed tokens.</summary>
public string TokenId { get; set; } = Guid.NewGuid().ToString("N"); public string TokenId { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>Subject identifier (user ID, service account, etc.).</summary> /// <summary>Subject of the token (user ID, service account name, etc.).</summary>
public string Subject { get; set; } = string.Empty; public string Subject { get; set; } = string.Empty;
/// <summary>Issuer of the token.</summary> /// <summary>Issuer that created and signed the token.</summary>
public string Issuer { get; set; } = string.Empty; public string Issuer { get; set; } = string.Empty;
/// <summary>Intended audience(s) for the token.</summary> /// <summary>One or more intended audiences.</summary>
public IReadOnlyList<string> Audiences { get; set; } = new List<string>(); public IReadOnlyList<string> Audiences { get; set; } = Array.Empty<string>();
/// <summary>Roles assigned to the subject.</summary> /// <summary>Roles granted to the subject.</summary>
public IReadOnlyList<string> Roles { get; set; } = new List<string>(); public IReadOnlyList<string> Roles { get; set; } = Array.Empty<string>();
/// <summary>Arbitrary key-value claims.</summary> /// <summary>Arbitrary application-defined key/value pairs.</summary>
public IReadOnlyDictionary<string, string> Custom { get; set; } = new Dictionary<string, string>(); public IReadOnlyDictionary<string, string> Custom { get; set; } = new Dictionary<string, string>();
/// <summary>UTC time when the token was issued.</summary> /// <summary>UTC instant the token was minted.</summary>
public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>UTC time after which the token is valid.</summary> /// <summary>UTC instant before which the token must not be accepted.</summary>
public DateTimeOffset NotBefore { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset NotBefore { get; set; } = DateTimeOffset.UtcNow;
/// <summary>UTC time when the token expires.</summary> /// <summary>UTC instant after which the token is no longer valid.</summary>
public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(1); public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(1);
/// <summary> /// <summary>
/// Optional context binding - token is only valid for a specific IP / device fingerprint / etc. /// Optional binding value (IP address, device fingerprint, TLS channel hash, etc.).
/// Prevents token theft across contexts. /// A token with a binding can only be used in the same context it was issued in.
/// </summary> /// </summary>
public string? BindingContext { get; set; } public string? BindingContext { get; set; }
/// <summary> /// <summary>
/// The generation of the signing key used. Enables seamless key rotation. /// The signing-key generation used. Allows the verifier to find the right key
/// after rotation without invalidating older tokens.
/// </summary> /// </summary>
public int KeyGeneration { get; set; } public int KeyGeneration { get; set; }
/// <summary>Token type tag - prevents cross-purpose token misuse.</summary> /// <summary>
/// Discriminates token purpose. Separate HKDF sub-keys are derived per type,
/// so a refresh token cannot pass signature verification as an access token.
/// </summary>
public string TokenType { get; set; } = TokenTypeConstants.Access; public string TokenType { get; set; } = TokenTypeConstants.Access;
public bool IsExpired(DateTimeOffset? now = null) => public bool IsExpired(DateTimeOffset? now = null) =>
@@ -59,12 +62,19 @@ namespace SecureToken.Core
public bool IsActive(DateTimeOffset? now = null) public bool IsActive(DateTimeOffset? now = null)
{ {
var utcNow = now ?? DateTimeOffset.UtcNow; var t = now ?? DateTimeOffset.UtcNow;
return utcNow >= NotBefore && utcNow < ExpiresAt; return t >= NotBefore && t < ExpiresAt;
} }
/// <summary>Remaining lifetime of this token (may be negative if expired).</summary>
public TimeSpan RemainingLifetime(DateTimeOffset? now = null) =>
ExpiresAt - (now ?? DateTimeOffset.UtcNow);
} }
/// <summary>Well-known token type constants to prevent cross-type confusion attacks.</summary> /// <summary>
/// Well-known token type constants. Each constant maps to an isolated HKDF sub-key,
/// so a token issued as one type cannot be replayed as another - even with a valid signature.
/// </summary>
public static class TokenTypeConstants public static class TokenTypeConstants
{ {
public const string Access = "at+secure"; public const string Access = "at+secure";
@@ -74,4 +84,4 @@ namespace SecureToken.Core
public const string PasswordReset = "pwr+secure"; public const string PasswordReset = "pwr+secure";
public const string EmailVerification = "ev+secure"; public const string EmailVerification = "ev+secure";
} }
} }
+118 -13
View File
@@ -1,17 +1,25 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace SecureToken.Core namespace EonaCat.SecureToken.Core
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary> /// <summary>
/// Fluent builder for constructing token claims before issuing. /// Fluent builder for constructing a token's claims before issuing.
/// All inputs are validated at build time so errors surface before a token is signed.
/// </summary> /// </summary>
public sealed class TokenDescriptor public sealed class TokenDescriptor
{ {
// Hard upper bounds that protect against misconfiguration producing
// tokens that are valid for an unexpectedly long time.
private static readonly TimeSpan MaxAccessTokenLifetime = TimeSpan.FromHours(24);
private static readonly TimeSpan MaxRefreshTokenLifetime = TimeSpan.FromDays(365);
private const int MaxCustomClaims = 32;
private const int MaxRoles = 64;
private const int MaxStringLength = 2048;
private string _subject = string.Empty; private string _subject = string.Empty;
private string _issuer = string.Empty; private string _issuer = string.Empty;
private readonly List<string> _audiences = new List<string>(); private readonly List<string> _audiences = new List<string>();
@@ -21,51 +29,91 @@ namespace SecureToken.Core
private TimeSpan _notBeforeDelay = TimeSpan.Zero; private TimeSpan _notBeforeDelay = TimeSpan.Zero;
private string? _bindingContext; private string? _bindingContext;
private string _tokenType = TokenTypeConstants.Access; private string _tokenType = TokenTypeConstants.Access;
private string? _tokenId;
private TokenDescriptor() { }
public static TokenDescriptor Create() => new TokenDescriptor(); public static TokenDescriptor Create() => new TokenDescriptor();
// Subject / issuer / audience
public TokenDescriptor ForSubject(string subject) public TokenDescriptor ForSubject(string subject)
{ {
ValidateString(nameof(subject), subject);
_subject = subject; _subject = subject;
return this; return this;
} }
public TokenDescriptor IssuedBy(string issuer) public TokenDescriptor IssuedBy(string issuer)
{ {
ValidateString(nameof(issuer), issuer);
_issuer = issuer; _issuer = issuer;
return this; return this;
} }
public TokenDescriptor ForAudience(string audience) public TokenDescriptor ForAudience(string audience)
{ {
ValidateString(nameof(audience), audience);
_audiences.Add(audience); _audiences.Add(audience);
return this; return this;
} }
public TokenDescriptor ForAudiences(IEnumerable<string> audiences) public TokenDescriptor ForAudiences(IEnumerable<string> audiences)
{ {
_audiences.AddRange(audiences); if (audiences is null)
{
throw new ArgumentNullException(nameof(audiences));
}
foreach (var a in audiences)
{
ForAudience(a);
}
return this; return this;
} }
// Roles
public TokenDescriptor WithRole(string role) public TokenDescriptor WithRole(string role)
{ {
ValidateString(nameof(role), role);
if (_roles.Count >= MaxRoles)
{
throw new InvalidOperationException($"A token may carry at most {MaxRoles} roles.");
}
_roles.Add(role); _roles.Add(role);
return this; return this;
} }
public TokenDescriptor WithRoles(IEnumerable<string> roles) public TokenDescriptor WithRoles(IEnumerable<string> roles)
{ {
_roles.AddRange(roles); if (roles is null)
{
throw new ArgumentNullException(nameof(roles));
}
foreach (var r in roles)
{
WithRole(r);
}
return this; return this;
} }
public TokenDescriptor WithClaim(string key, string value) public TokenDescriptor WithClaim(string key, string value)
{ {
ValidateString(nameof(key), key);
ValidateString(nameof(value), value);
if (_custom.Count >= MaxCustomClaims && !_custom.ContainsKey(key))
{
throw new InvalidOperationException($"A token may carry at most {MaxCustomClaims} custom claims.");
}
_custom[key] = value; _custom[key] = value;
return this; return this;
} }
// Lifetime
public TokenDescriptor WithLifetime(TimeSpan lifetime) public TokenDescriptor WithLifetime(TimeSpan lifetime)
{ {
if (lifetime <= TimeSpan.Zero) if (lifetime <= TimeSpan.Zero)
@@ -80,22 +128,35 @@ namespace SecureToken.Core
public TokenDescriptor ExpiresIn(int minutes) => public TokenDescriptor ExpiresIn(int minutes) =>
WithLifetime(TimeSpan.FromMinutes(minutes)); WithLifetime(TimeSpan.FromMinutes(minutes));
public TokenDescriptor NotValidBefore(TimeSpan delay)
{
if (delay < TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(delay), "Delay must be non-negative.");
}
_notBeforeDelay = delay;
return this;
}
/// <summary> /// <summary>
/// Bind this token to a specific context (IP address, device fingerprint, etc.). /// Binds this token to a specific context value (IP address, device fingerprint, etc.).
/// The same binding must be provided during validation. /// The same value must be supplied on every validation call; mismatches are rejected.
/// </summary> /// </summary>
public TokenDescriptor BoundTo(string context) public TokenDescriptor BoundTo(string context)
{ {
ValidateString(nameof(context), context);
_bindingContext = context; _bindingContext = context;
return this; return this;
} }
/// <summary> /// <summary>
/// Tag the token type to prevent cross-purpose usage. /// Sets a custom token-type tag. Use <see cref="TokenTypeConstants"/> for well-known values,
/// Use <see cref="TokenTypeConstants"/> for well-known values. /// or supply your own namespaced string (e.g. <c>"workspace-invite+v1"</c>).
/// </summary> /// </summary>
public TokenDescriptor OfType(string tokenType) public TokenDescriptor OfType(string tokenType)
{ {
ValidateString(nameof(tokenType), tokenType);
_tokenType = tokenType; _tokenType = tokenType;
return this; return this;
} }
@@ -103,22 +164,52 @@ namespace SecureToken.Core
public TokenDescriptor AsRefreshToken() => OfType(TokenTypeConstants.Refresh); public TokenDescriptor AsRefreshToken() => OfType(TokenTypeConstants.Refresh);
public TokenDescriptor AsServiceAccount() => OfType(TokenTypeConstants.ServiceAccount); public TokenDescriptor AsServiceAccount() => OfType(TokenTypeConstants.ServiceAccount);
public TokenDescriptor NotValidBefore(TimeSpan delay) /// <summary>
/// Overrides the auto-generated token ID. Use this only when you need to
/// correlate the token with an externally-tracked identifier.
/// </summary>
public TokenDescriptor WithTokenId(string tokenId)
{ {
_notBeforeDelay = delay; ValidateString(nameof(tokenId), tokenId);
_tokenId = tokenId;
return this; return this;
} }
// Build
internal TokenClaims Build(int keyGeneration = 0) internal TokenClaims Build(int keyGeneration = 0)
{ {
if (string.IsNullOrWhiteSpace(_subject))
{
throw new InvalidOperationException("A token must have a subject. Call ForSubject(...).");
}
if (string.IsNullOrWhiteSpace(_issuer))
{
throw new InvalidOperationException("A token must have an issuer. Call IssuedBy(...).");
}
// Enforce maximum lifetime per token type to prevent misconfiguration
var maxLifetime = _tokenType == TokenTypeConstants.Refresh
? MaxRefreshTokenLifetime
: MaxAccessTokenLifetime;
if (_lifetime > maxLifetime)
{
throw new InvalidOperationException(
$"Lifetime of {_lifetime.TotalDays:F1} days exceeds the maximum of " +
$"{maxLifetime.TotalDays:F1} days for token type '{_tokenType}'. " +
"Use AsServiceAccount() and set an explicit long lifetime for service accounts.");
}
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
return new TokenClaims return new TokenClaims
{ {
TokenId = _tokenId ?? Guid.NewGuid().ToString("N"),
Subject = _subject, Subject = _subject,
Issuer = _issuer, Issuer = _issuer,
Audiences = _audiences.AsReadOnly(), Audiences = _audiences.AsReadOnly(),
Roles = _roles.AsReadOnly(), Roles = _roles.AsReadOnly(),
Custom = _custom, Custom = new Dictionary<string, string>(_custom),
IssuedAt = now, IssuedAt = now,
NotBefore = now + _notBeforeDelay, NotBefore = now + _notBeforeDelay,
ExpiresAt = now + _lifetime, ExpiresAt = now + _lifetime,
@@ -127,5 +218,19 @@ namespace SecureToken.Core
KeyGeneration = keyGeneration, KeyGeneration = keyGeneration,
}; };
} }
private static void ValidateString(string paramName, string value)
{
if (string.IsNullOrEmpty(value))
{
throw new ArgumentException($"'{paramName}' must not be null or empty.", paramName);
}
if (value.Length > MaxStringLength)
{
throw new ArgumentException(
$"'{paramName}' must not exceed {MaxStringLength} characters.", paramName);
}
}
} }
} }
+2 -2
View File
@@ -1,8 +1,8 @@
using SecureToken.Core; using EonaCat.SecureToken.Core;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
namespace SecureToken namespace EonaCat.SecureToken
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
+143 -15
View File
@@ -1,26 +1,154 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text;
namespace SecureToken.Core namespace EonaCat.SecureToken.Core
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary>
/// Discriminated union returned by every validation call.
/// Validation never throws - every possible failure has a named case so callers
/// are forced to handle each outcome explicitly at compile time.
/// </summary>
public abstract class TokenResult public abstract class TokenResult
{ {
private TokenResult() { } // Seal the hierarchy: only the cases defined here can exist.
public sealed class Success : TokenResult { public string Token; public TokenClaims Claims; public Success(string token, TokenClaims claims) { Token = token; Claims = claims; } } private protected TokenResult() { }
public sealed class Expired : TokenResult { public DateTimeOffset ExpiredAt; public Expired(DateTimeOffset v) { ExpiredAt = v; } }
public sealed class InvalidSignature : TokenResult { } /// <summary>Token is valid. Claims are safe to use for authorization.</summary>
public sealed class NotYetValid : TokenResult { public DateTimeOffset ValidFrom; public NotYetValid(DateTimeOffset v) { ValidFrom = v; } } public sealed class Success : TokenResult
public sealed class WrongAudience : TokenResult { public string Expected; public IReadOnlyList<string> Actual; public WrongAudience(string e, IReadOnlyList<string> a) { Expected = e; Actual = a; } } {
public sealed class WrongTokenType : TokenResult { public string Expected; public string Actual; public WrongTokenType(string e, string a) { Expected = e; Actual = a; } } public string Token { get; }
public sealed class UntrustedIssuer : TokenResult { public string Issuer; public UntrustedIssuer(string i) { Issuer = i; } } public TokenClaims Claims { get; }
public sealed class BindingMismatch : TokenResult { public string Reason; public BindingMismatch(string r) { Reason = r; } } public Success(string token, TokenClaims claims)
public sealed class Revoked : TokenResult { public string TokenId; public Revoked(string t) { TokenId = t; } } {
public sealed class Malformed : TokenResult { public string Reason; public Malformed(string r) { Reason = r; } } Token = token ?? throw new ArgumentNullException(nameof(token));
Claims = claims ?? throw new ArgumentNullException(nameof(claims));
}
public override string ToString() => $"Success(sub={Claims.Subject}, exp={Claims.ExpiresAt:u})";
}
/// <summary>Token's expiry timestamp is in the past (plus clock-skew tolerance).</summary>
public sealed class Expired : TokenResult
{
public DateTimeOffset ExpiredAt { get; }
public Expired(DateTimeOffset expiredAt) { ExpiredAt = expiredAt; }
public override string ToString() => $"Expired(at={ExpiredAt:u})";
}
/// <summary>HMAC signature does not match the payload.</summary>
public sealed class InvalidSignature : TokenResult
{
public override string ToString() => "InvalidSignature";
}
/// <summary>Token is not yet valid (<c>nbf</c> claim is in the future).</summary>
public sealed class NotYetValid : TokenResult
{
public DateTimeOffset ValidFrom { get; }
public NotYetValid(DateTimeOffset validFrom) { ValidFrom = validFrom; }
public override string ToString() => $"NotYetValid(from={ValidFrom:u})";
}
/// <summary>Token's audience list does not include the expected audience.</summary>
public sealed class WrongAudience : TokenResult
{
public string Expected { get; }
public IReadOnlyList<string> Actual { get; }
public WrongAudience(string expected, IReadOnlyList<string> actual)
{
Expected = expected;
Actual = actual;
}
public override string ToString() =>
$"WrongAudience(expected={Expected}, actual=[{string.Join(",", Actual)}])";
}
/// <summary>Token type tag does not match the required type.</summary>
public sealed class WrongTokenType : TokenResult
{
public string Expected { get; }
public string Actual { get; }
public WrongTokenType(string expected, string actual)
{
Expected = expected;
Actual = actual;
}
public override string ToString() =>
$"WrongTokenType(expected={Expected}, actual={Actual})";
}
/// <summary>Token was issued by an untrusted issuer.</summary>
public sealed class UntrustedIssuer : TokenResult
{
public string Issuer { get; }
public UntrustedIssuer(string issuer) { Issuer = issuer; }
public override string ToString() => $"UntrustedIssuer(iss={Issuer})";
}
/// <summary>
/// Token's binding context does not match the context provided at validation time.
/// This indicates a stolen token being replayed from a different IP, device, or session.
/// </summary>
public sealed class BindingMismatch : TokenResult
{
public string Reason { get; }
public BindingMismatch(string reason) { Reason = reason; }
public override string ToString() => $"BindingMismatch({Reason})";
}
/// <summary>Token ID was found in the revocation store.</summary>
public sealed class Revoked : TokenResult
{
public string TokenId { get; }
public Revoked(string tokenId) { TokenId = tokenId; }
public override string ToString() => $"Revoked(jti={TokenId})";
}
/// <summary>Token string is structurally invalid (wrong prefix, bad Base64, corrupt payload).</summary>
public sealed class Malformed : TokenResult
{
public string Reason { get; }
public Malformed(string reason) { Reason = reason; }
public override string ToString() => $"Malformed({Reason})";
}
public bool IsSuccess => this is Success; public bool IsSuccess => this is Success;
public TokenClaims UnwrapClaims() { var s = this as Success; if (s != null) { return s.Claims; } throw new InvalidOperationException(); }
public T Match<T>(Func<Success, T> onSuccess, Func<TokenResult, T> onFailure) { var s = this as Success; return s != null ? onSuccess(s) : onFailure(this); } /// <summary>
/// Unwraps the claims from a successful result.
/// Throws <see cref="InvalidOperationException"/> if the result is not a success.
/// Prefer pattern-matching or <see cref="Match{T}"/> to avoid accidental throws.
/// </summary>
public TokenClaims UnwrapClaims()
{
if (this is Success s)
{
return s.Claims;
}
throw new InvalidOperationException(
$"Cannot unwrap claims from a failed token result: {this}");
}
/// <summary>
/// Functional-style fold over the result. Avoids exposing unsafe unwrap paths.
/// </summary>
public T Match<T>(Func<Success, T> onSuccess, Func<TokenResult, T> onFailure)
{
if (onSuccess is null)
{
throw new ArgumentNullException(nameof(onSuccess));
}
if (onFailure is null)
{
throw new ArgumentNullException(nameof(onFailure));
}
return this is Success s ? onSuccess(s) : onFailure(this);
}
} }
} }
+5 -5
View File
@@ -1,13 +1,13 @@
using SecureToken.Core; using EonaCat.SecureToken.Core;
using SecureToken.Cryptography; using EonaCat.SecureToken.Cryptography;
using SecureToken.Tokens; using EonaCat.SecureToken.Tokens;
using SecureToken.Validation; using EonaCat.SecureToken.Validation;
using System; using System;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace SecureToken namespace EonaCat.SecureToken
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
@@ -1,61 +1,112 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using EonaCat.SecureToken.Core;
namespace SecureToken.Validation namespace EonaCat.SecureToken.Validation
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
/// <summary> /// <summary>
/// Options used when validating a token. Fine-grained control over each check. /// Controls which security checks are applied when validating a token.
/// All checks are enabled by default (secure-by-default stance).
/// Disable individual checks only when you have a specific, documented reason.
/// </summary> /// </summary>
public sealed class TokenValidationOptions public sealed class TokenValidationOptions
{ {
/// <summary>Expected issuer. Null skips issuer validation.</summary> /// <summary>
/// Expected issuer (<c>iss</c>). Null disables issuer validation - not recommended.
/// </summary>
public string? ValidIssuer { get; set; } public string? ValidIssuer { get; set; }
/// <summary>Expected audience. Null skips audience validation.</summary> /// <summary>
/// Expected audience (<c>aud</c>). Null disables audience validation - not recommended.
/// </summary>
public string? ValidAudience { get; set; } public string? ValidAudience { get; set; }
/// <summary>Expected token type. Null skips type validation.</summary> /// <summary>
/// Required token-type tag. Null disables type validation - not recommended outside
/// of special diagnostic scenarios.
/// </summary>
public string? RequiredTokenType { get; set; } public string? RequiredTokenType { get; set; }
/// <summary> /// <summary>
/// Binding context - must match the value used when the token was issued. /// When set, the token's stored binding context must exactly match this value.
/// Null skips binding validation (not recommended for high-security tokens). /// Recommended for high-value tokens (e.g. bind to the client's IP or TLS channel hash).
/// Null means binding is not checked, even if the token carries a binding context.
/// </summary> /// </summary>
public string? BindingContext { get; set; } public string? BindingContext { get; set; }
/// <summary>Clock skew tolerance. Defaults to 30 seconds.</summary> /// <summary>
/// Maximum allowed clock difference between issuer and validator.
/// Defaults to 30 seconds. Setting this to zero is possible but can cause false rejections
/// due to normal NTP skew. Values above 5 minutes weaken replay protection.
/// </summary>
public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30); public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>Whether to validate the not-before claim. Default: true.</summary> /// <summary>Whether to enforce the <c>nbf</c> (not-before) claim. Default: true.</summary>
public bool ValidateNotBefore { get; set; } = true; public bool ValidateNotBefore { get; set; } = true;
/// <summary>Whether to validate expiry. Default: true.</summary> /// <summary>Whether to enforce the <c>exp</c> (expires-at) claim. Default: true.</summary>
public bool ValidateExpiry { get; set; } = true; public bool ValidateExpiry { get; set; } = true;
/// <summary> /// <summary>
/// Pluggable revocation check. Return true if the token is revoked. /// Pluggable async revocation check. Return <c>true</c> if the given <c>jti</c> is revoked.
/// Hook this into your cache or database for real-time revocation. /// Hook this into Redis, a database, or an in-memory cache.
/// Null means no revocation check is performed (fine for short-lived access tokens where
/// the expiry window is your revocation window).
/// </summary> /// </summary>
public Func<string, CancellationToken, Task<bool>>? RevocationCheck { get; set; } public Func<string, CancellationToken, Task<bool>>? RevocationCheck { get; set; }
/// <summary>Convenience: validates as an access token for a given audience.</summary> /// <summary>
public static TokenValidationOptions AccessToken(string issuer, string audience, string? bindingContext = null) => new TokenValidationOptions() /// Optional absolute maximum age for accepted tokens, measured from <c>iat</c>.
{ /// Provides a defense-in-depth backstop even if the <c>exp</c> claim was set unusually far ahead.
ValidIssuer = issuer, /// Null means no additional check beyond the standard expiry.
ValidAudience = audience, /// </summary>
RequiredTokenType = Core.TokenTypeConstants.Access, public TimeSpan? MaxTokenAge { get; set; }
BindingContext = bindingContext,
};
/// <summary>Convenience: validates as a refresh token.</summary> /// <summary>
public static TokenValidationOptions RefreshToken(string issuer) => new TokenValidationOptions() /// Strict access-token options: issuer, audience, type, and optional binding context.
{ /// Suitable for most API endpoint authorization.
ValidIssuer = issuer, /// </summary>
RequiredTokenType = Core.TokenTypeConstants.Refresh, public static TokenValidationOptions AccessToken(
}; string issuer,
string audience,
string? bindingContext = null) =>
new TokenValidationOptions
{
ValidIssuer = issuer,
ValidAudience = audience,
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.
/// </summary>
public static TokenValidationOptions RefreshToken(string issuer) =>
new TokenValidationOptions
{
ValidIssuer = issuer,
RequiredTokenType = TokenTypeConstants.Refresh,
// Refresh tokens are long-lived; revocation check is strongly recommended.
};
/// <summary>
/// One-time-use token options (password reset, email verification, invitation).
/// Sets a short maximum age as a defense-in-depth backstop.
/// </summary>
public static TokenValidationOptions OneTimeUse(
string issuer,
string tokenType,
TimeSpan maxAge) =>
new TokenValidationOptions
{
ValidIssuer = issuer,
RequiredTokenType = tokenType,
MaxTokenAge = maxAge,
};
} }
} }
+2 -2
View File
@@ -1,9 +1,9 @@
using SecureToken.Core; using EonaCat.SecureToken.Core;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Text; using System.Text;
namespace SecureToken.Tokens namespace EonaCat.SecureToken.Tokens
{ {
// This file is part of the EonaCat project(s) which is released under the Apache License. // 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details.
+216 -288
View File
@@ -1,343 +1,271 @@
# EonaCat.SecureToken # EonaCat.SecureToken
**A cryptographically superior alternative to JWT for .NET 8+** **Secure, modern token library for .NET with key rotation, signing isolation, validation rules, and .NET Standard support.**
EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens. It focuses on:
- Strong cryptographic signing
- Versioned key management
- Token lifecycle validation
- Refresh/access token separation
- Extensible claims
- API-friendly validation results
- `.NET Standard 2.0` compatibility
## Features
### Cryptographic protection
- HMAC based token signing
- HKDF derived context-specific keys
- Constant-time signature verification
- Tamper detection
- Strong random key generation
### Key rotation
Rotate signing keys without invalidating existing tokens.
Example:
```csharp
var store = SigningKeyStore.CreateNew();
var service = new TokenService(store);
var oldToken = service.Issue(
TokenDescriptor.Create()
.ForSubject("user-123")
.IssuedBy("my-api")
.ForAudience("mobile")
);
// Rotate keys
store.Rotate();
// New tokens use the new key
var newToken = service.Issue(
TokenDescriptor.Create()
.ForSubject("user-456")
.IssuedBy("my-api")
.ForAudience("mobile")
);
// Old token still validates
service.Validate(
oldToken,
TokenValidationOptions.AccessToken("my-api", "mobile")
);
```
## Installation ## Installation
Install from NuGet:
```bash ```bash
dotnet add package SecureToken dotnet add package EonaCat.SecureToken
``` ```
## Quick Start # Quick Start
### 1. Register with DI (ASP.NET Core) ## Create a token service
```csharp ```csharp
// Program.cs using EonaCat.SecureToken.Core;
using SecureToken.Extensions; using EonaCat.SecureToken.Cryptography;
builder.Services.AddSecureTokens(sp => var keys = SigningKeyStore.CreateNew();
{
// Load your key material from environment / secrets manager var tokens = new TokenService(keys);
var keyBytes = Convert.FromBase64String(Environment.GetEnvironmentVariable("TOKEN_KEY")!);
return SigningKeyStore.FromKeys([(1, keyBytes)]);
});
``` ```
For development / testing, a random ephemeral key is fine: ## Issue an access token
```csharp ```csharp
builder.Services.AddSecureTokens(); // random key, resets on restart var token = tokens.Issue(
```
### 2. Issue a Token
```csharp
public class AuthController : ControllerBase
{
private readonly ITokenService _tokens;
public AuthController(ITokenService tokens) => _tokens = tokens;
[HttpPost("login")]
public IActionResult Login([FromBody] LoginRequest req)
{
// ... verify credentials ...
var token = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject(user.Id)
.IssuedBy("my-api")
.ForAudience("my-api")
.WithRole("user")
.WithClaim("email", user.Email)
.ExpiresIn(minutes: 15)
);
return Ok(new { token });
}
}
```
### 3. Validate a Token
```csharp
var result = await _tokens.ValidateAsync(
rawToken,
TokenValidationOptions.AccessToken(issuer: "my-api", audience: "my-api")
);
// Pattern-match the result - no exceptions thrown
switch (result)
{
case TokenResult.Success s:
var userId = s.Claims.Subject;
var roles = s.Claims.Roles;
break;
case TokenResult.Expired e:
return Unauthorized($"Token expired at {e.ExpiredAt}");
case TokenResult.InvalidSignature:
return Unauthorized("Token signature is invalid.");
case TokenResult.WrongTokenType w:
return Unauthorized($"Expected {w.Expected}, got {w.Actual}");
case TokenResult.Revoked r:
return Unauthorized($"Token {r.TokenId} has been revoked.");
default:
return Unauthorized("Token validation failed.");
}
```
Or use the match helper:
```csharp
return result.Match(
onSuccess: s => Ok(new { s.Claims.Subject }),
onFailure: err => Unauthorized(err.ToString())
);
```
## Token Pairs (Access + Refresh)
Issue a short-lived access token and a long-lived refresh token in one call:
```csharp
var pair = _tokens.IssueTokenPair(
subject: user.Id,
issuer: "my-api",
audience: "my-api",
roles: ["admin"],
claims: [new("plan", "pro")],
accessTokenLifetime: TimeSpan.FromMinutes(15),
refreshTokenLifetime: TimeSpan.FromDays(30)
);
return Ok(new
{
accessToken: pair.AccessToken,
refreshToken: pair.RefreshToken,
expiresAt: pair.AccessTokenExpiry,
});
```
Refresh tokens are **type-tagged** - they cannot be used where an access token is expected, and vice versa.
```csharp
// Refreshing
var refreshResult = await _tokens.ValidateAsync(
refreshToken,
TokenValidationOptions.RefreshToken("my-api")
);
if (refreshResult is TokenResult.Success s)
{
var newPair = _tokens.IssueTokenPair(s.Claims.Subject, "my-api", "my-api");
return Ok(newPair);
}
```
## Context Binding
Bind a token to a client context (IP address, device fingerprint, TLS channel hash, etc.).
A stolen token from another context will fail validation.
```csharp
// Issue - bind to the client's IP
var token = _tokens.Issue(
TokenDescriptor.Create() TokenDescriptor.Create()
.ForSubject(user.Id) .ForSubject("user-123")
.IssuedBy("my-api") .IssuedBy("my-service")
.ForAudience("my-api") .ForAudience("api")
.BoundTo(clientIpAddress) // <-- context binding .WithRole("admin")
.WithClaim("email", "user@example.com")
);
```
The token contains:
- Subject
- Issuer
- Audience
- Roles
- Custom claims
- Token ID
- Expiration
- Key generation information
## Validate a token
```csharp
var result = tokens.Validate(
token,
TokenValidationOptions.AccessToken(
issuer: "my-service",
audience: "api"
)
); );
// Validate - must supply the same context if (result.IsSuccess)
var result = await _tokens.ValidateAsync(token, new TokenValidationOptions
{ {
ValidIssuer = "my-api", var claims = result.UnwrapClaims();
ValidAudience = "my-api",
RequiredTokenType = TokenTypeConstants.Access, Console.WriteLine(claims.Subject);
BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), }
});
``` ```
If the IP doesn't match, validation returns `TokenResult.BindingMismatch`. # Token expiration
## Key Rotation
Keys are versioned. Old tokens remain valid after rotation until they expire naturally.
```csharp ```csharp
// Inject SigningKeyStore and rotate var token = tokens.Issue(
var newGeneration = _keyStore.Rotate(); TokenDescriptor.Create()
.ForSubject("user-1")
// Or supply new key material from your KMS .IssuedBy("api")
var newGeneration = _keyStore.Rotate(myKmsBytes); .ForAudience("mobile")
.WithLifetime(TimeSpan.FromMinutes(15))
// Prune old generation once all tokens from it have expired );
_keyStore.PruneGeneration(oldGeneration);
``` ```
Each token embeds the key generation it was signed with. The verifier automatically picks the right key. Expired tokens are automatically rejected.
# Refresh tokens
Create a refresh token:
## Revocation
Plug in any store - Redis, database, or in-memory:
```csharp ```csharp
// Example: Redis-backed revocation var pair = tokens.IssueTokenPair(
var options = new TokenValidationOptions "user-1",
"api",
"mobile"
);
Console.WriteLine(pair.AccessToken);
Console.WriteLine(pair.RefreshToken);
```
Validate separately:
```csharp
tokens.Validate(
pair.RefreshToken,
TokenValidationOptions.RefreshToken("api")
);
```
Refresh tokens cannot be used as access tokens.
# Token binding
Bind tokens to a context such as a device or session:
```csharp
var token = tokens.Issue(
TokenDescriptor.Create()
.ForSubject("user-1")
.IssuedBy("api")
.ForAudience("web")
.BoundTo("device-identifier")
);
```
Validation:
```csharp
new TokenValidationOptions
{ {
ValidIssuer = "my-api", ValidIssuer = "api",
ValidAudience = "my-api", ValidAudience = "web",
RequiredTokenType = TokenTypeConstants.Access, BindingContext = "device-identifier"
RevocationCheck = async (tokenId, ct) =>
{
return await _redis.KeyExistsAsync($"revoked:{tokenId}");
}
}; };
// To revoke a token:
var claims = _tokens.Inspect(tokenString);
await _redis.StringSetAsync($"revoked:{claims!.TokenId}", "1", claims.ExpiresAt - DateTimeOffset.UtcNow);
``` ```
# Revocation
You can integrate your own revocation storage:
## Custom Token Types
Prevent tokens from being used across different purposes:
```csharp
// Issue an invitation token
var inviteToken = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject(inviteeEmail)
.IssuedBy("my-api")
.OfType(TokenTypeConstants.Invitation) // or your own string, e.g. "workspace-invite+v1"
.WithClaim("workspaceId", workspaceId)
.ExpiresIn(minutes: 60 * 48) // 48 hours
);
// Validate - only accepts invitation tokens
var result = svc.Validate(token, new TokenValidationOptions
{
ValidIssuer = "my-api",
RequiredTokenType = TokenTypeConstants.Invitation,
});
```
Built-in type constants in `TokenTypeConstants`:
| Constant | Value |
|||
| `Access` | `at+secure` |
| `Refresh` | `rt+secure` |
| `ServiceAccount` | `sa+secure` |
| `Invitation` | `inv+secure` |
| `PasswordReset` | `pwr+secure` |
| `EmailVerification` | `ev+secure` |
## Service Account Tokens
```csharp
var saToken = _tokens.Issue(
TokenDescriptor.Create()
.ForSubject("svc-worker-1")
.IssuedBy("my-api")
.ForAudience("internal-queue")
.AsServiceAccount()
.WithRole("queue-publisher")
.WithLifetime(TimeSpan.FromDays(365))
);
```
## Full Validation Options Reference
```csharp ```csharp
var options = new TokenValidationOptions var options = new TokenValidationOptions
{ {
// Issuer the token must have been issued by ValidIssuer = "api",
ValidIssuer = "my-api", ValidAudience = "web",
// Audience the token must include RevocationCheck = async (tokenId, cancellationToken) =>
ValidAudience = "my-api", {
return await database.IsRevoked(tokenId);
// Token type that must match exactly }
RequiredTokenType = TokenTypeConstants.Access,
// Context binding (IP, device hash, etc.)
BindingContext = clientIp,
// Clock skew tolerance (default: 30 seconds)
ClockSkew = TimeSpan.FromSeconds(30),
// Toggle individual checks
ValidateExpiry = true,
ValidateNotBefore = true,
// Async revocation check - skip for sync validation
RevocationCheck = async (tokenId, ct) =>
await _revokedIds.ContainsAsync(tokenId, ct),
}; };
``` ```
# ASP.NET Core dependency injection
## Inspecting Tokens (Debug Only)
```csharp ```csharp
// No signature check - for diagnostics, never for authorization builder.Services.AddSecureTokens();
var claims = _tokens.Inspect(rawToken);
Console.WriteLine($"Subject: {claims?.Subject}");
Console.WriteLine($"Issued: {claims?.IssuedAt}");
Console.WriteLine($"Expires: {claims?.ExpiresAt}");
Console.WriteLine($"KeyGen: {claims?.KeyGeneration}");
``` ```
or provide your own key store:
```csharp
## Token Format builder.Services.AddSecureTokens(
store =>
``` {
stv1.{base64url(payload)}.{base64url(signature)} return SigningKeyStore.FromKeys(
new[]
{
(1, secretKeyBytes)
});
});
``` ```
- `stv1` - version prefix, never changes algorithm. # Security design
- `payload` - compact binary-encoded claims (not JSON).
- `signature` - HMAC-SHA256 over the payload using an HKDF-derived sub-key.
The sub-key is derived from the master key using HKDF with the **token type as context**, so a signing key used for access tokens cannot verify refresh tokens even if an attacker switches the type tag. The library separates cryptographic purposes:
```
Master Key
|
+-- Signing Key
|
+-- Encryption Key
|
+-- Context-specific keys
```
This prevents accidental key reuse between operations.
## Security Properties # Supported frameworks
- **No algorithm field in token** - the verifier always decides the algorithm. - .NET Standard 2.0
- **Binary payload** - no JSON parser, no prototype pollution, no ambiguous number types. - .NET Standard 2.1
- **Type-segregated sub-keys** - HKDF derives a separate key per token type. - .NET 8+
- **Constant-time comparison** - signature verification uses `CryptographicOperations.FixedTimeEquals`.
- **Typed results, no exceptions** - validation never throws; every failure is a named case. # When to use
- **Context binding** - tokens can be tied to an IP, device, or TLS channel binding.
- **Versioned key rotation** - rotate keys without invalidating unexpired tokens. Good fit for:
APIs
Microservices
Internal authentication
Service-to-service tokens
Applications needing key rotation
# When not to use
Do not store secrets directly in source code.
Use:
- Environment variables
- Secret managers
- Hardware-backed key storage where required
# License
Apache License.