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