Added .net standard 2.0 and .net framework 4.8 support
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user