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 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<TokenResult.WrongTokenType>();
}
// ── Revocation ───────────────────────────────────────────────────────────
// Revocation
[Fact]
public async Task RevocationCheck_RevokedToken_ReturnsRevoked()
{
@@ -237,8 +233,7 @@ namespace SecureToken.Tests
result.Should().BeOfType<TokenResult.Revoked>();
}
// ── Inspect (no signature check) ─────────────────────────────────────────
// Inspect (no signature check)
[Fact]
public void Inspect_MalformedToken_ReturnsNull()
{
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>netstandard2.0;net4.8;net8.0</TargetFrameworks>
<ApplicationIcon>icon.ico</ApplicationIcon>
<LangVersion>latest</LangVersion>
<Authors>EonaCat (Jeroen Saey)</Authors>
@@ -8,13 +8,13 @@
<Company>EonaCat (Jeroen Saey)</Company>
<PackageIcon>icon.png</PackageIcon>
<PackageProjectUrl>https://www.nuget.org/packages/EonaCat.SecureToken/</PackageProjectUrl>
<Description>A modern, cryptographically superior alternative to JWT. SecureToken,
structured binding, built-in rotation, and opaque reference tokens with zero parsing vulnerabilities.</Description>
<Description>EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens.
Secure, modern token library for .NET with key rotation, signing isolation and validation rules</Description>
<PackageReleaseNotes>Public release version</PackageReleaseNotes>
<Copyright>EonaCat (Jeroen Saey)</Copyright>
<PackageTags>EonaCat;authentication;token;paseto;security;auth;jwt-alternative;Jeroen;Saey</PackageTags>
<PackageIconUrl />
<FileVersion>0.1.1</FileVersion>
<FileVersion>0.0.2</FileVersion>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
@@ -25,7 +25,7 @@
</PropertyGroup>
<PropertyGroup>
<EVRevisionFormat>0.0.1+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVRevisionFormat>0.0.2+{chash:10}.{c:ymd}</EVRevisionFormat>
<EVDefault>true</EVDefault>
<EVInfo>true</EVInfo>
<EVTagMatch>v[0-9]*</EVTagMatch>
@@ -36,7 +36,7 @@
</PropertyGroup>
<PropertyGroup>
<Version>0.0.1</Version>
<Version>0.0.2</Version>
<PackageId>EonaCat.SecureToken</PackageId>
<Product>EonaCat.SecureToken</Product>
<RepositoryUrl>https://git.saey.me/EonaCat/EonaCat.SecureToken</RepositoryUrl>
@@ -73,8 +73,8 @@
</PackageReference>
<PackageReference Include="EonaCat.Versioning.Helpers" Version="1.5.1" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Net.Http.Headers" Version="2.3.11" />
<PackageReference Include="System.Memory" Version="4.5.5" />
</ItemGroup>
@@ -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.
@@ -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.
+218 -27
View File
@@ -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
/// <summary>
/// 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.
/// </summary>
public sealed class SigningKeyStore
public sealed class SigningKeyStore : IDisposable
{
private readonly SortedDictionary<int, byte[]> _keys = new SortedDictionary<int, byte[]>();
private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
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() { }
/// <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()
{
var store = new SigningKeyStore();
store.AddNewKey(RandomNumberGenerator.GetBytes(64));
store.AddNewKey(GenerateKey(MinimumKeyBytes));
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)
{
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;
}
/// <summary>
/// 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>
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);
}
/// <summary>Removes expired key generations to free memory. Only remove if all tokens from that generation are expired.</summary>
public void PruneGeneration(int generation) => _keys.Remove(generation);
/// <summary>
/// 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)
{
_lock.EnterReadLock();
try
{
if (_keys.TryGetValue(generation, out var k))
{
key = k;
return true;
}
key = new byte[32];
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();
}
}
private int AddNewKey(byte[] material)
{
_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));
}
}
}
/// <summary>
/// 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.
/// </summary>
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";
/// <summary>
/// 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.
/// </summary>
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);
}
public static byte[] Sign(byte[] signingKey, byte[] payload) =>
HMACSHA256.HashData(signingKey, payload);
// HKDF Expand with a domain-specific info string
var info = Encoding.UTF8.GetBytes(SigningInfo + "|" + context);
return HkdfExpand(prk, info, 64);
}
private static byte[] HkdfExpand(byte[] prk, byte[] info, int length)
{
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)
{
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;
}
}
/// <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.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.
/// <summary>
/// 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.
/// </summary>
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");
/// <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;
/// <summary>Issuer of the token.</summary>
/// <summary>Issuer that created and signed the token.</summary>
public string Issuer { get; set; } = string.Empty;
/// <summary>Intended audience(s) for the token.</summary>
public IReadOnlyList<string> Audiences { get; set; } = new List<string>();
/// <summary>One or more intended audiences.</summary>
public IReadOnlyList<string> Audiences { get; set; } = Array.Empty<string>();
/// <summary>Roles assigned to the subject.</summary>
public IReadOnlyList<string> Roles { get; set; } = new List<string>();
/// <summary>Roles granted to the subject.</summary>
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>();
/// <summary>UTC time when the token was issued.</summary>
/// <summary>UTC instant the token was minted.</summary>
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;
/// <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);
/// <summary>
/// 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.
/// </summary>
public string? BindingContext { get; set; }
/// <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>
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 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;
}
/// <summary>Well-known token type constants to prevent cross-type confusion attacks.</summary>
/// <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. 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 const string Access = "at+secure";
+117 -12
View File
@@ -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.
/// <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>
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<string> _audiences = new List<string>();
@@ -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<string> 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<string> 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;
}
/// <summary>
/// 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.
/// </summary>
public TokenDescriptor BoundTo(string context)
{
ValidateString(nameof(context), context);
_bindingContext = context;
return this;
}
/// <summary>
/// Tag the token type to prevent cross-purpose usage.
/// Use <see cref="TokenTypeConstants"/> for well-known values.
/// Sets a custom token-type tag. Use <see cref="TokenTypeConstants"/> for well-known values,
/// or supply your own namespaced string (e.g. <c>"workspace-invite+v1"</c>).
/// </summary>
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)
/// <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;
}
// 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<string, string>(_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);
}
}
}
}
+2 -2
View File
@@ -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.
+142 -14
View File
@@ -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.
/// <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
{
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<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 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() { }
/// <summary>Token is valid. Claims are safe to use for authorization.</summary>
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})";
}
/// <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 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 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.
@@ -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.
/// <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>
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; }
/// <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; }
/// <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; }
/// <summary>
/// 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.
/// </summary>
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);
/// <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;
/// <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;
/// <summary>
/// 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 <c>true</c> if the given <c>jti</c> 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).
/// </summary>
public Func<string, CancellationToken, Task<bool>>? RevocationCheck { get; set; }
/// <summary>Convenience: validates as an access token for a given audience.</summary>
public static TokenValidationOptions AccessToken(string issuer, string audience, string? bindingContext = null) => new TokenValidationOptions()
/// <summary>
/// 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.
/// Null means no additional check beyond the standard expiry.
/// </summary>
public TimeSpan? MaxTokenAge { get; set; }
/// <summary>
/// Strict access-token options: issuer, audience, type, and optional binding context.
/// Suitable for most API endpoint authorization.
/// </summary>
public static TokenValidationOptions AccessToken(
string issuer,
string audience,
string? bindingContext = null) =>
new TokenValidationOptions
{
ValidIssuer = issuer,
ValidAudience = audience,
RequiredTokenType = Core.TokenTypeConstants.Access,
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = bindingContext,
};
/// <summary>Convenience: validates as a refresh token.</summary>
public static TokenValidationOptions RefreshToken(string issuer) => new TokenValidationOptions()
/// <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 = Core.TokenTypeConstants.Refresh,
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.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.
+200 -272
View File
@@ -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(
var token = tokens.Issue(
TokenDescriptor.Create()
.ForSubject(user.Id)
.IssuedBy("my-api")
.ForAudience("my-api")
.WithRole("user")
.WithClaim("email", user.Email)
.ExpiresIn(minutes: 15)
.ForSubject("user-123")
.IssuedBy("my-service")
.ForAudience("api")
.WithRole("admin")
.WithClaim("email", "user@example.com")
);
return Ok(new { token });
}
}
```
The token contains:
- Subject
- Issuer
- Audience
- Roles
- Custom claims
- Token ID
- Expiration
- Key generation information
### 3. Validate a Token
## Validate a token
```csharp
var result = await _tokens.ValidateAsync(
rawToken,
TokenValidationOptions.AccessToken(issuer: "my-api", audience: "my-api")
var result = tokens.Validate(
token,
TokenValidationOptions.AccessToken(
issuer: "my-service",
audience: "api"
)
);
// Pattern-match the result - no exceptions thrown
switch (result)
if (result.IsSuccess)
{
case TokenResult.Success s:
var userId = s.Claims.Subject;
var roles = s.Claims.Roles;
break;
var claims = result.UnwrapClaims();
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.");
Console.WriteLine(claims.Subject);
}
```
Or use the match helper:
# Token expiration
```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-1")
.IssuedBy("api")
.ForAudience("mobile")
.WithLifetime(TimeSpan.FromMinutes(15))
);
```
Expired tokens are automatically rejected.
# Refresh tokens
Create a refresh token:
```csharp
var pair = tokens.IssueTokenPair(
"user-1",
"api",
"mobile"
);
// Validate - must supply the same context
var result = await _tokens.ValidateAsync(token, new TokenValidationOptions
{
ValidIssuer = "my-api",
ValidAudience = "my-api",
RequiredTokenType = TokenTypeConstants.Access,
BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
});
Console.WriteLine(pair.AccessToken);
Console.WriteLine(pair.RefreshToken);
```
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.
Validate separately:
```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);
tokens.Validate(
pair.RefreshToken,
TokenValidationOptions.RefreshToken("api")
);
```
Each token embeds the key generation it was signed with. The verifier automatically picks the right key.
Refresh tokens cannot be used as access tokens.
# Token binding
## Revocation
Plug in any store - Redis, database, or in-memory:
Bind tokens to a context such as a device or session:
```csharp
// Example: Redis-backed revocation
var options = new TokenValidationOptions
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.
- .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.