From 8b2a776da455031c268a6340c6c1d32c05173d51 Mon Sep 17 00:00:00 2001 From: EonaCat Date: Fri, 19 Jun 2026 20:17:05 +0200 Subject: [PATCH] Added .net standard 2.0 and .net framework 4.8 support --- .../TokenServiceTests.cs | 23 +- .../EonaCat.SecureToken.csproj | 16 +- .../Extensions/ServiceCollectionExtensions.cs | 5 +- .../Middleware/SecureTokenMiddleware.cs | 4 +- EonaCat.SecureToken/Models/SigningKeyStore.cs | 263 +++++++-- EonaCat.SecureToken/Models/TokenClaims.cs | 56 +- EonaCat.SecureToken/Models/TokenDescriptor.cs | 131 ++++- EonaCat.SecureToken/Models/TokenPair.cs | 4 +- EonaCat.SecureToken/Models/TokenResult.cs | 158 +++++- EonaCat.SecureToken/Models/TokenService.cs | 10 +- .../Models/TokenValidationOptions.cs | 105 +++- EonaCat.SecureToken/TokenSerializer.cs | 4 +- README.md | 504 ++++++++---------- 13 files changed, 845 insertions(+), 438 deletions(-) diff --git a/EonaCat.SecureToken.Tests/TokenServiceTests.cs b/EonaCat.SecureToken.Tests/TokenServiceTests.cs index 11a8e34..8c6ba9c 100644 --- a/EonaCat.SecureToken.Tests/TokenServiceTests.cs +++ b/EonaCat.SecureToken.Tests/TokenServiceTests.cs @@ -1,18 +1,17 @@ using FluentAssertions; -using SecureToken.Core; -using SecureToken.Cryptography; -using SecureToken.Validation; +using EonaCat.SecureToken.Core; +using EonaCat.SecureToken.Cryptography; +using EonaCat.SecureToken.Validation; using Xunit; -namespace SecureToken.Tests +namespace EonaCat.SecureToken.Tests { public sealed class TokenServiceTests { private static ITokenService CreateService(SigningKeyStore? store = null) => new TokenService(store ?? SigningKeyStore.CreateNew()); - // ── Issue & Validate ───────────────────────────────────────────────────── - + // Issue & Validate [Fact] public void Issue_And_Validate_ReturnsSuccess() { @@ -142,8 +141,7 @@ namespace SecureToken.Tests result.IsSuccess.Should().BeTrue(); } - // ── Key Rotation ───────────────────────────────────────────────────────── - + // Key Rotation [Fact] public void KeyRotation_OldTokensRemainValid() { @@ -186,8 +184,7 @@ namespace SecureToken.Tests result.IsSuccess.Should().BeTrue(); } - // ── Token Pair ─────────────────────────────────────────────────────────── - + // Token Pair [Fact] public void IssueTokenPair_ProducesValidPair() { @@ -213,8 +210,7 @@ namespace SecureToken.Tests result.Should().BeOfType(); } - // ── Revocation ─────────────────────────────────────────────────────────── - + // Revocation [Fact] public async Task RevocationCheck_RevokedToken_ReturnsRevoked() { @@ -237,8 +233,7 @@ namespace SecureToken.Tests result.Should().BeOfType(); } - // ── Inspect (no signature check) ───────────────────────────────────────── - + // Inspect (no signature check) [Fact] public void Inspect_MalformedToken_ReturnsNull() { diff --git a/EonaCat.SecureToken/EonaCat.SecureToken.csproj b/EonaCat.SecureToken/EonaCat.SecureToken.csproj index b2bd2ae..e007346 100644 --- a/EonaCat.SecureToken/EonaCat.SecureToken.csproj +++ b/EonaCat.SecureToken/EonaCat.SecureToken.csproj @@ -1,6 +1,6 @@  - net8.0 + netstandard2.0;net4.8;net8.0 icon.ico latest EonaCat (Jeroen Saey) @@ -8,13 +8,13 @@ EonaCat (Jeroen Saey) icon.png https://www.nuget.org/packages/EonaCat.SecureToken/ - A modern, cryptographically superior alternative to JWT. SecureToken, - structured binding, built-in rotation, and opaque reference tokens with zero parsing vulnerabilities. + 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 Public release version EonaCat (Jeroen Saey) EonaCat;authentication;token;paseto;security;auth;jwt-alternative;Jeroen;Saey - 0.1.1 + 0.0.2 README.md True LICENSE @@ -25,7 +25,7 @@ - 0.0.1+{chash:10}.{c:ymd} + 0.0.2+{chash:10}.{c:ymd} true true v[0-9]* @@ -36,7 +36,7 @@ - 0.0.1 + 0.0.2 EonaCat.SecureToken EonaCat.SecureToken https://git.saey.me/EonaCat/EonaCat.SecureToken @@ -73,8 +73,8 @@ - - + + diff --git a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs index c393664..cd21be8 100644 --- a/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs +++ b/EonaCat.SecureToken/Extensions/ServiceCollectionExtensions.cs @@ -1,9 +1,8 @@ using Microsoft.Extensions.DependencyInjection; -using SecureToken.Cryptography; +using EonaCat.SecureToken.Cryptography; using System; - -namespace SecureToken.Extensions +namespace EonaCat.SecureToken.Extensions { // 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. diff --git a/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs b/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs index d9908e4..aa6f6da 100644 --- a/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs +++ b/EonaCat.SecureToken/Middleware/SecureTokenMiddleware.cs @@ -1,12 +1,12 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -using SecureToken.Validation; +using EonaCat.SecureToken.Validation; using System; using System.Linq; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. diff --git a/EonaCat.SecureToken/Models/SigningKeyStore.cs b/EonaCat.SecureToken/Models/SigningKeyStore.cs index bd147a9..b5e9bb7 100644 --- a/EonaCat.SecureToken/Models/SigningKeyStore.cs +++ b/EonaCat.SecureToken/Models/SigningKeyStore.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. @@ -10,31 +13,45 @@ namespace SecureToken.Cryptography /// /// Manages a versioned set of signing keys, enabling seamless key rotation. /// Old tokens remain verifiable while new tokens always use the latest key. + /// Thread-safe: all reads and writes are protected by a ReaderWriterLockSlim. /// - public sealed class SigningKeyStore + public sealed class SigningKeyStore : IDisposable { private readonly SortedDictionary _keys = new SortedDictionary(); + private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(); private int _currentGeneration; + private bool _disposed; + + /// Minimum key size in bytes (64 = 512 bits for HMAC-SHA512). + public const int MinimumKeyBytes = 64; private SigningKeyStore() { } - /// Creates a new key store with a single randomly generated key. + /// Creates a new key store with a single cryptographically-random key. public static SigningKeyStore CreateNew() { var store = new SigningKeyStore(); - store.AddNewKey(RandomNumberGenerator.GetBytes(64)); + store.AddNewKey(GenerateKey(MinimumKeyBytes)); return store; } - /// Creates a key store from an existing set of versioned keys. + /// + /// Creates a key store from an existing set of versioned keys. + /// Throws if any key is shorter than . + /// public static SigningKeyStore FromKeys(IEnumerable<(int generation, byte[] keyMaterial)> keys) { + if (keys is null) + { + throw new ArgumentNullException(nameof(keys)); + } + var store = new SigningKeyStore(); 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; @@ -43,75 +60,249 @@ namespace SecureToken.Cryptography store._currentGeneration = gen; } } + if (store._keys.Count == 0) + { + throw new ArgumentException("At least one key must be supplied."); + } + return store; } /// /// 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. /// 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); } - /// Removes expired key generations to free memory. Only remove if all tokens from that generation are expired. - 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. + /// + 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(); } + } + + /// The generation number of the key that will be used to sign new tokens. + public int CurrentGeneration + { + get + { + _lock.EnterReadLock(); + try { return _currentGeneration; } + finally { _lock.ExitReadLock(); } + } + } internal bool TryGetKey(int generation, out byte[] key) { - if (_keys.TryGetValue(generation, out var k)) + _lock.EnterReadLock(); + try { - key = k; - return true; + if (_keys.TryGetValue(generation, out var k)) + { + key = k; + return true; + } + key = Array.Empty(); + return false; + } + finally { _lock.ExitReadLock(); } + } + + /// Zeroes all signing material and releases the lock. + 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) { - var gen = _currentGeneration + 1; - _keys[gen] = material; - _currentGeneration = gen; - return gen; + _lock.EnterWriteLock(); + try + { + 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)); + } } } /// - /// Low-level HMAC-SHA256 signer. All signing is deterministic and uses a separate - /// per-field HKDF-derived sub-key to prevent cross-context attacks. + /// Low-level HMAC-SHA512 signer with HKDF-derived sub-keys. + /// Signing is deterministic; each token type gets its own isolated key. /// internal static class TokenSigner { - private const string SigningInfo = "SecureToken-v1-Signing"; - private const string EncryptionInfo = "SecureToken-v1-Encryption"; + private const string SigningInfo = "EonaCat.SecureToken|v2|Signing"; /// - /// Derives a context-specific signing sub-key using HKDF to prevent - /// the same key material being misused across different contexts. + /// Derives a context-specific signing sub-key using HKDF-SHA512 to prevent + /// the same master key material from being usable across different token types. /// public static byte[] DeriveSigningKey(byte[] masterKey, string context) { - // HKDF-Extract + HKDF-Expand - var prk = HMACSHA256.HashData(masterKey, System.Text.Encoding.UTF8.GetBytes(context)); - var info = System.Text.Encoding.UTF8.GetBytes(SigningInfo + "|" + context); - return HKDF.Expand(HashAlgorithmName.SHA256, prk, 32, info); + // HKDF Extract: PRK = HMAC-SHA512(salt=masterKey, IKM=context) + byte[] prk; + using (var hmac = new HMACSHA512(masterKey)) + { + 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) => - HMACSHA256.HashData(signingKey, payload); + private static byte[] HkdfExpand(byte[] prk, byte[] info, int length) + { + using var hmac = new HMACSHA512(prk); + var output = new List(length + 64); + byte[] previous = Array.Empty(); + 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); + } + + /// + /// Verifies that matches the HMAC of . + /// 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. + /// public static bool Verify(byte[] signingKey, byte[] payload, byte[] signature) { + if (payload is null || signature is null) + { + return false; + } + var expected = Sign(signingKey, payload); - // Constant-time comparison to prevent timing attacks 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; + } } -} + + /// + /// Uses for constant-time + /// byte comparison when available (.NET Core 2.1+), falling back gracefully otherwise. + /// + 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; + } + } +} \ No newline at end of file diff --git a/EonaCat.SecureToken/Models/TokenClaims.cs b/EonaCat.SecureToken/Models/TokenClaims.cs index ff897e5..b65003b 100644 --- a/EonaCat.SecureToken/Models/TokenClaims.cs +++ b/EonaCat.SecureToken/Models/TokenClaims.cs @@ -1,57 +1,60 @@ using System; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. /// - /// Represents the claims payload of a SecureToken. - /// Strongly-typed and tamper-evident - no algorithm confusion possible. + /// Represents the verified claims payload of a SecureToken. + /// All fields are strongly-typed. No JSON or string-map ambiguity. /// public sealed class TokenClaims { - /// Unique token identifier (prevents replay attacks). + /// Unique token identifier. Used to detect replayed tokens. public string TokenId { get; set; } = Guid.NewGuid().ToString("N"); - /// Subject identifier (user ID, service account, etc.). + /// Subject of the token (user ID, service account name, etc.). public string Subject { get; set; } = string.Empty; - /// Issuer of the token. + /// Issuer that created and signed the token. public string Issuer { get; set; } = string.Empty; - /// Intended audience(s) for the token. - public IReadOnlyList Audiences { get; set; } = new List(); + /// One or more intended audiences. + public IReadOnlyList Audiences { get; set; } = Array.Empty(); - /// Roles assigned to the subject. - public IReadOnlyList Roles { get; set; } = new List(); + /// Roles granted to the subject. + public IReadOnlyList Roles { get; set; } = Array.Empty(); - /// Arbitrary key-value claims. + /// Arbitrary application-defined key/value pairs. public IReadOnlyDictionary Custom { get; set; } = new Dictionary(); - /// UTC time when the token was issued. + /// UTC instant the token was minted. public DateTimeOffset IssuedAt { get; set; } = DateTimeOffset.UtcNow; - /// UTC time after which the token is valid. + /// UTC instant before which the token must not be accepted. public DateTimeOffset NotBefore { get; set; } = DateTimeOffset.UtcNow; - /// UTC time when the token expires. + /// UTC instant after which the token is no longer valid. public DateTimeOffset ExpiresAt { get; set; } = DateTimeOffset.UtcNow.AddHours(1); /// - /// Optional context binding - token is only valid for a specific IP / device fingerprint / etc. - /// Prevents token theft across contexts. + /// Optional binding value (IP address, device fingerprint, TLS channel hash, etc.). + /// A token with a binding can only be used in the same context it was issued in. /// public string? BindingContext { get; set; } /// - /// 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. /// public int KeyGeneration { get; set; } - /// Token type tag - prevents cross-purpose token misuse. + /// + /// Discriminates token purpose. Separate HKDF sub-keys are derived per type, + /// so a refresh token cannot pass signature verification as an access token. + /// public string TokenType { get; set; } = TokenTypeConstants.Access; public bool IsExpired(DateTimeOffset? now = null) => @@ -59,12 +62,19 @@ namespace SecureToken.Core public bool IsActive(DateTimeOffset? now = null) { - var utcNow = now ?? DateTimeOffset.UtcNow; - return utcNow >= NotBefore && utcNow < ExpiresAt; + var t = now ?? DateTimeOffset.UtcNow; + return t >= NotBefore && t < ExpiresAt; } + + /// Remaining lifetime of this token (may be negative if expired). + public TimeSpan RemainingLifetime(DateTimeOffset? now = null) => + ExpiresAt - (now ?? DateTimeOffset.UtcNow); } - /// Well-known token type constants to prevent cross-type confusion attacks. + /// + /// 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. + /// public static class TokenTypeConstants { public const string Access = "at+secure"; @@ -74,4 +84,4 @@ namespace SecureToken.Core public const string PasswordReset = "pwr+secure"; public const string EmailVerification = "ev+secure"; } -} +} \ No newline at end of file diff --git a/EonaCat.SecureToken/Models/TokenDescriptor.cs b/EonaCat.SecureToken/Models/TokenDescriptor.cs index 7d8d64f..00dca68 100644 --- a/EonaCat.SecureToken/Models/TokenDescriptor.cs +++ b/EonaCat.SecureToken/Models/TokenDescriptor.cs @@ -1,17 +1,25 @@ using System; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. - /// - /// 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. /// 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 _issuer = string.Empty; private readonly List _audiences = new List(); @@ -21,51 +29,91 @@ namespace SecureToken.Core private TimeSpan _notBeforeDelay = TimeSpan.Zero; private string? _bindingContext; private string _tokenType = TokenTypeConstants.Access; + private string? _tokenId; + + private TokenDescriptor() { } public static TokenDescriptor Create() => new TokenDescriptor(); + // Subject / issuer / audience public TokenDescriptor ForSubject(string subject) { + ValidateString(nameof(subject), subject); _subject = subject; return this; } public TokenDescriptor IssuedBy(string issuer) { + ValidateString(nameof(issuer), issuer); _issuer = issuer; return this; } public TokenDescriptor ForAudience(string audience) { + ValidateString(nameof(audience), audience); _audiences.Add(audience); return this; } public TokenDescriptor ForAudiences(IEnumerable audiences) { - _audiences.AddRange(audiences); + if (audiences is null) + { + throw new ArgumentNullException(nameof(audiences)); + } + + foreach (var a in audiences) + { + ForAudience(a); + } + return this; } + // Roles 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); return this; } public TokenDescriptor WithRoles(IEnumerable roles) { - _roles.AddRange(roles); + if (roles is null) + { + throw new ArgumentNullException(nameof(roles)); + } + + foreach (var r in roles) + { + WithRole(r); + } + return this; } 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; return this; } + // Lifetime public TokenDescriptor WithLifetime(TimeSpan lifetime) { if (lifetime <= TimeSpan.Zero) @@ -80,22 +128,35 @@ namespace SecureToken.Core public TokenDescriptor ExpiresIn(int 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; + } + /// - /// Bind this token to a specific context (IP address, device fingerprint, etc.). - /// The same binding must be provided during validation. + /// Binds this token to a specific context value (IP address, device fingerprint, etc.). + /// The same value must be supplied on every validation call; mismatches are rejected. /// public TokenDescriptor BoundTo(string context) { + ValidateString(nameof(context), context); _bindingContext = context; return this; } /// - /// Tag the token type to prevent cross-purpose usage. - /// Use for well-known values. + /// Sets a custom token-type tag. Use for well-known values, + /// or supply your own namespaced string (e.g. "workspace-invite+v1"). /// public TokenDescriptor OfType(string tokenType) { + ValidateString(nameof(tokenType), tokenType); _tokenType = tokenType; return this; } @@ -103,22 +164,52 @@ namespace SecureToken.Core public TokenDescriptor AsRefreshToken() => OfType(TokenTypeConstants.Refresh); public TokenDescriptor AsServiceAccount() => OfType(TokenTypeConstants.ServiceAccount); - public TokenDescriptor NotValidBefore(TimeSpan delay) + /// + /// Overrides the auto-generated token ID. Use this only when you need to + /// correlate the token with an externally-tracked identifier. + /// + public TokenDescriptor WithTokenId(string tokenId) { - _notBeforeDelay = delay; + ValidateString(nameof(tokenId), tokenId); + _tokenId = tokenId; return this; } + // Build 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; return new TokenClaims { + TokenId = _tokenId ?? Guid.NewGuid().ToString("N"), Subject = _subject, Issuer = _issuer, Audiences = _audiences.AsReadOnly(), Roles = _roles.AsReadOnly(), - Custom = _custom, + Custom = new Dictionary(_custom), IssuedAt = now, NotBefore = now + _notBeforeDelay, ExpiresAt = now + _lifetime, @@ -127,5 +218,19 @@ namespace SecureToken.Core 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); + } + } } -} +} \ No newline at end of file diff --git a/EonaCat.SecureToken/Models/TokenPair.cs b/EonaCat.SecureToken/Models/TokenPair.cs index 36edebb..a4040ee 100644 --- a/EonaCat.SecureToken/Models/TokenPair.cs +++ b/EonaCat.SecureToken/Models/TokenPair.cs @@ -1,8 +1,8 @@ -using SecureToken.Core; +using EonaCat.SecureToken.Core; using System; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. diff --git a/EonaCat.SecureToken/Models/TokenResult.cs b/EonaCat.SecureToken/Models/TokenResult.cs index 7ec9010..6d70fa1 100644 --- a/EonaCat.SecureToken/Models/TokenResult.cs +++ b/EonaCat.SecureToken/Models/TokenResult.cs @@ -1,26 +1,154 @@ using System; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. + /// + /// 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. + /// public abstract class TokenResult { - private TokenResult() { } - public sealed class Success : TokenResult { public string Token; public TokenClaims Claims; public Success(string token, TokenClaims claims) { Token = token; Claims = claims; } } - public sealed class Expired : TokenResult { public DateTimeOffset ExpiredAt; public Expired(DateTimeOffset v) { ExpiredAt = v; } } - public sealed class InvalidSignature : TokenResult { } - public sealed class NotYetValid : TokenResult { public DateTimeOffset ValidFrom; public NotYetValid(DateTimeOffset v) { ValidFrom = v; } } - public sealed class WrongAudience : TokenResult { public string Expected; public IReadOnlyList Actual; public WrongAudience(string e, IReadOnlyList 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 sealed class UntrustedIssuer : TokenResult { public string Issuer; public UntrustedIssuer(string i) { Issuer = i; } } - public sealed class BindingMismatch : TokenResult { public string Reason; public BindingMismatch(string r) { Reason = r; } } - 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; } } + // Seal the hierarchy: only the cases defined here can exist. + private protected TokenResult() { } + + /// Token is valid. Claims are safe to use for authorization. + public sealed class Success : TokenResult + { + public string Token { get; } + public TokenClaims Claims { get; } + public Success(string token, TokenClaims claims) + { + 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})"; + } + + /// Token's expiry timestamp is in the past (plus clock-skew tolerance). + public sealed class Expired : TokenResult + { + public DateTimeOffset ExpiredAt { get; } + public Expired(DateTimeOffset expiredAt) { ExpiredAt = expiredAt; } + public override string ToString() => $"Expired(at={ExpiredAt:u})"; + } + + /// HMAC signature does not match the payload. + public sealed class InvalidSignature : TokenResult + { + public override string ToString() => "InvalidSignature"; + } + + /// Token is not yet valid (nbf claim is in the future). + public sealed class NotYetValid : TokenResult + { + public DateTimeOffset ValidFrom { get; } + public NotYetValid(DateTimeOffset validFrom) { ValidFrom = validFrom; } + public override string ToString() => $"NotYetValid(from={ValidFrom:u})"; + } + + /// Token's audience list does not include the expected audience. + public sealed class WrongAudience : TokenResult + { + public string Expected { get; } + public IReadOnlyList Actual { get; } + public WrongAudience(string expected, IReadOnlyList actual) + { + Expected = expected; + Actual = actual; + } + public override string ToString() => + $"WrongAudience(expected={Expected}, actual=[{string.Join(",", Actual)}])"; + } + + /// Token type tag does not match the required type. + 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})"; + } + + /// Token was issued by an untrusted issuer. + public sealed class UntrustedIssuer : TokenResult + { + public string Issuer { get; } + public UntrustedIssuer(string issuer) { Issuer = issuer; } + public override string ToString() => $"UntrustedIssuer(iss={Issuer})"; + } + + /// + /// 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. + /// + public sealed class BindingMismatch : TokenResult + { + public string Reason { get; } + public BindingMismatch(string reason) { Reason = reason; } + public override string ToString() => $"BindingMismatch({Reason})"; + } + + /// Token ID was found in the revocation store. + public sealed class Revoked : TokenResult + { + public string TokenId { get; } + public Revoked(string tokenId) { TokenId = tokenId; } + public override string ToString() => $"Revoked(jti={TokenId})"; + } + + /// Token string is structurally invalid (wrong prefix, bad Base64, corrupt payload). + 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 TokenClaims UnwrapClaims() { var s = this as Success; if (s != null) { return s.Claims; } throw new InvalidOperationException(); } - public T Match(Func onSuccess, Func onFailure) { var s = this as Success; return s != null ? onSuccess(s) : onFailure(this); } + + /// + /// Unwraps the claims from a successful result. + /// Throws if the result is not a success. + /// Prefer pattern-matching or to avoid accidental throws. + /// + public TokenClaims UnwrapClaims() + { + if (this is Success s) + { + return s.Claims; + } + + throw new InvalidOperationException( + $"Cannot unwrap claims from a failed token result: {this}"); + } + + /// + /// Functional-style fold over the result. Avoids exposing unsafe unwrap paths. + /// + public T Match(Func onSuccess, Func 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); + } } -} +} \ No newline at end of file diff --git a/EonaCat.SecureToken/Models/TokenService.cs b/EonaCat.SecureToken/Models/TokenService.cs index 7d10c6c..16bd337 100644 --- a/EonaCat.SecureToken/Models/TokenService.cs +++ b/EonaCat.SecureToken/Models/TokenService.cs @@ -1,13 +1,13 @@ -using SecureToken.Core; -using SecureToken.Cryptography; -using SecureToken.Tokens; -using SecureToken.Validation; +using EonaCat.SecureToken.Core; +using EonaCat.SecureToken.Cryptography; +using EonaCat.SecureToken.Tokens; +using EonaCat.SecureToken.Validation; using System; using System.Linq; using System.Threading; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. diff --git a/EonaCat.SecureToken/Models/TokenValidationOptions.cs b/EonaCat.SecureToken/Models/TokenValidationOptions.cs index 9e51aac..02084c6 100644 --- a/EonaCat.SecureToken/Models/TokenValidationOptions.cs +++ b/EonaCat.SecureToken/Models/TokenValidationOptions.cs @@ -1,61 +1,112 @@ using System; using System.Threading; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. /// - /// 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. /// public sealed class TokenValidationOptions { - /// Expected issuer. Null skips issuer validation. + /// + /// Expected issuer (iss). Null disables issuer validation - not recommended. + /// public string? ValidIssuer { get; set; } - /// Expected audience. Null skips audience validation. + /// + /// Expected audience (aud). Null disables audience validation - not recommended. + /// public string? ValidAudience { get; set; } - /// Expected token type. Null skips type validation. + /// + /// Required token-type tag. Null disables type validation - not recommended outside + /// of special diagnostic scenarios. + /// public string? RequiredTokenType { get; set; } /// - /// Binding context - must match the value used when the token was issued. - /// Null skips binding validation (not recommended for high-security tokens). + /// When set, the token's stored binding context must exactly match this value. + /// 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. /// public string? BindingContext { get; set; } - /// Clock skew tolerance. Defaults to 30 seconds. + /// + /// 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. + /// public TimeSpan ClockSkew { get; set; } = TimeSpan.FromSeconds(30); - /// Whether to validate the not-before claim. Default: true. + /// Whether to enforce the nbf (not-before) claim. Default: true. public bool ValidateNotBefore { get; set; } = true; - /// Whether to validate expiry. Default: true. + /// Whether to enforce the exp (expires-at) claim. Default: true. public bool ValidateExpiry { get; set; } = true; /// - /// Pluggable revocation check. Return true if the token is revoked. - /// Hook this into your cache or database for real-time revocation. + /// Pluggable async revocation check. Return true if the given jti is revoked. + /// 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). /// public Func>? RevocationCheck { get; set; } - /// Convenience: validates as an access token for a given audience. - public static TokenValidationOptions AccessToken(string issuer, string audience, string? bindingContext = null) => new TokenValidationOptions() - { - ValidIssuer = issuer, - ValidAudience = audience, - RequiredTokenType = Core.TokenTypeConstants.Access, - BindingContext = bindingContext, - }; + /// + /// Optional absolute maximum age for accepted tokens, measured from iat. + /// Provides a defense-in-depth backstop even if the exp claim was set unusually far ahead. + /// Null means no additional check beyond the standard expiry. + /// + public TimeSpan? MaxTokenAge { get; set; } - /// Convenience: validates as a refresh token. - public static TokenValidationOptions RefreshToken(string issuer) => new TokenValidationOptions() - { - ValidIssuer = issuer, - RequiredTokenType = Core.TokenTypeConstants.Refresh, - }; + /// + /// Strict access-token options: issuer, audience, type, and optional binding context. + /// Suitable for most API endpoint authorization. + /// + public static TokenValidationOptions AccessToken( + string issuer, + string audience, + string? bindingContext = null) => + new TokenValidationOptions + { + ValidIssuer = issuer, + ValidAudience = audience, + RequiredTokenType = TokenTypeConstants.Access, + BindingContext = bindingContext, + }; + + /// + /// Refresh-token options. Audience is intentionally not required here because + /// refresh tokens are typically consumed at the authorization server, not forwarded. + /// + public static TokenValidationOptions RefreshToken(string issuer) => + new TokenValidationOptions + { + ValidIssuer = issuer, + RequiredTokenType = TokenTypeConstants.Refresh, + // Refresh tokens are long-lived; revocation check is strongly recommended. + }; + + /// + /// One-time-use token options (password reset, email verification, invitation). + /// Sets a short maximum age as a defense-in-depth backstop. + /// + public static TokenValidationOptions OneTimeUse( + string issuer, + string tokenType, + TimeSpan maxAge) => + new TokenValidationOptions + { + ValidIssuer = issuer, + RequiredTokenType = tokenType, + MaxTokenAge = maxAge, + }; } -} +} \ No newline at end of file diff --git a/EonaCat.SecureToken/TokenSerializer.cs b/EonaCat.SecureToken/TokenSerializer.cs index e2706f2..08b520d 100644 --- a/EonaCat.SecureToken/TokenSerializer.cs +++ b/EonaCat.SecureToken/TokenSerializer.cs @@ -1,9 +1,9 @@ -using SecureToken.Core; +using EonaCat.SecureToken.Core; using System; using System.Collections.Generic; 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. // See the LICENSE file or go to https://EonaCat.com/License for full license details. diff --git a/README.md b/README.md index 2c096b0..219dd2c 100644 --- a/README.md +++ b/README.md @@ -1,343 +1,271 @@ # 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 +Install from NuGet: + ```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 -// Program.cs -using SecureToken.Extensions; +using EonaCat.SecureToken.Core; +using EonaCat.SecureToken.Cryptography; -builder.Services.AddSecureTokens(sp => -{ - // Load your key material from environment / secrets manager - var keyBytes = Convert.FromBase64String(Environment.GetEnvironmentVariable("TOKEN_KEY")!); - return SigningKeyStore.FromKeys([(1, keyBytes)]); -}); +var keys = SigningKeyStore.CreateNew(); + +var tokens = new TokenService(keys); ``` -For development / testing, a random ephemeral key is fine: +## Issue an access token ```csharp -builder.Services.AddSecureTokens(); // random key, resets on restart -``` - - - -### 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( +var token = tokens.Issue( TokenDescriptor.Create() - .ForSubject(user.Id) - .IssuedBy("my-api") - .ForAudience("my-api") - .BoundTo(clientIpAddress) // <-- context binding + .ForSubject("user-123") + .IssuedBy("my-service") + .ForAudience("api") + .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 -var result = await _tokens.ValidateAsync(token, new TokenValidationOptions +if (result.IsSuccess) { - ValidIssuer = "my-api", - ValidAudience = "my-api", - RequiredTokenType = TokenTypeConstants.Access, - BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), -}); + var claims = result.UnwrapClaims(); + + Console.WriteLine(claims.Subject); +} ``` -If the IP doesn't match, validation returns `TokenResult.BindingMismatch`. - - - -## Key Rotation - -Keys are versioned. Old tokens remain valid after rotation until they expire naturally. +# Token expiration ```csharp -// Inject SigningKeyStore and rotate -var newGeneration = _keyStore.Rotate(); - -// Or supply new key material from your KMS -var newGeneration = _keyStore.Rotate(myKmsBytes); - -// Prune old generation once all tokens from it have expired -_keyStore.PruneGeneration(oldGeneration); +var token = tokens.Issue( + TokenDescriptor.Create() + .ForSubject("user-1") + .IssuedBy("api") + .ForAudience("mobile") + .WithLifetime(TimeSpan.FromMinutes(15)) +); ``` -Each token embeds the key generation it was signed with. The verifier automatically picks the right key. +Expired tokens are automatically rejected. +# Refresh tokens - -## Revocation - -Plug in any store - Redis, database, or in-memory: +Create a refresh token: ```csharp -// Example: Redis-backed revocation -var options = new TokenValidationOptions +var pair = tokens.IssueTokenPair( + "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", - ValidAudience = "my-api", - RequiredTokenType = TokenTypeConstants.Access, - RevocationCheck = async (tokenId, ct) => - { - return await _redis.KeyExistsAsync($"revoked:{tokenId}"); - } + ValidIssuer = "api", + ValidAudience = "web", + BindingContext = "device-identifier" }; - -// To revoke a token: -var claims = _tokens.Inspect(tokenString); -await _redis.StringSetAsync($"revoked:{claims!.TokenId}", "1", claims.ExpiresAt - DateTimeOffset.UtcNow); ``` +# Revocation - -## 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 +You can integrate your own revocation storage: ```csharp var options = new TokenValidationOptions { - // Issuer the token must have been issued by - ValidIssuer = "my-api", + ValidIssuer = "api", + ValidAudience = "web", - // Audience the token must include - ValidAudience = "my-api", - - // 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), + RevocationCheck = async (tokenId, cancellationToken) => + { + return await database.IsRevoked(tokenId); + } }; ``` - - -## Inspecting Tokens (Debug Only) +# ASP.NET Core dependency injection ```csharp -// No signature check - for diagnostics, never for authorization -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}"); +builder.Services.AddSecureTokens(); ``` +or provide your own key store: - -## Token Format - -``` -stv1.{base64url(payload)}.{base64url(signature)} +```csharp +builder.Services.AddSecureTokens( + store => + { + return SigningKeyStore.FromKeys( + new[] + { + (1, secretKeyBytes) + }); + }); ``` -- `stv1` - version prefix, never changes algorithm. -- `payload` - compact binary-encoded claims (not JSON). -- `signature` - HMAC-SHA256 over the payload using an HKDF-derived sub-key. +# Security design -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. -- **Binary payload** - no JSON parser, no prototype pollution, no ambiguous number types. -- **Type-segregated sub-keys** - HKDF derives a separate key per token type. -- **Constant-time comparison** - signature verification uses `CryptographicOperations.FixedTimeEquals`. -- **Typed results, no exceptions** - validation never throws; every failure is a named case. -- **Context binding** - tokens can be tied to an IP, device, or TLS channel binding. -- **Versioned key rotation** - rotate keys without invalidating unexpired tokens. \ No newline at end of file +- .NET Standard 2.0 +- .NET Standard 2.1 +- .NET 8+ + +# When to use + +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. \ No newline at end of file