348 lines
7.4 KiB
Markdown
348 lines
7.4 KiB
Markdown
# EonaCat.SecureToken
|
|
|
|
**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 (including binding-context comparison)
|
|
- Tamper detection
|
|
- Strong random key generation
|
|
- Bounded decoding: oversized or field-flooded tokens are rejected before signature
|
|
verification, so malformed input cannot be used to exhaust memory
|
|
|
|
### Validation
|
|
|
|
- Issuer, audience (single or multi-audience accept-list), and token-type checks
|
|
- Expiry, not-before, and an independent `MaxTokenAge` backstop measured from issuance
|
|
- Token binding (IP, device fingerprint, TLS channel hash, etc.)
|
|
- Pluggable revocation check
|
|
- Pluggable replay-cache for one-time-use tokens (password reset, email verification, invitations)
|
|
- `OnValidated` audit hook fired for every validation attempt
|
|
|
|
### 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 EonaCat.SecureToken
|
|
```
|
|
|
|
# Quick Start
|
|
|
|
## Create a token service
|
|
|
|
```csharp
|
|
using EonaCat.SecureToken.Core;
|
|
using EonaCat.SecureToken.Cryptography;
|
|
|
|
var keys = SigningKeyStore.CreateNew();
|
|
|
|
var tokens = new TokenService(keys);
|
|
```
|
|
|
|
## Issue an access token
|
|
|
|
```csharp
|
|
var token = tokens.Issue(
|
|
TokenDescriptor.Create()
|
|
.ForSubject("user-123")
|
|
.IssuedBy("my-service")
|
|
.ForAudience("api")
|
|
.WithRole("admin")
|
|
.WithClaim("email", "user@example.com")
|
|
);
|
|
```
|
|
|
|
The token contains:
|
|
|
|
- Subject
|
|
- Issuer
|
|
- Audience
|
|
- Roles
|
|
- Custom claims
|
|
- Token ID
|
|
- Expiration
|
|
- Key generation information
|
|
|
|
|
|
## Validate a token
|
|
|
|
```csharp
|
|
var result = tokens.Validate(
|
|
token,
|
|
TokenValidationOptions.AccessToken(
|
|
issuer: "my-service",
|
|
audience: "api"
|
|
)
|
|
);
|
|
|
|
if (result.IsSuccess)
|
|
{
|
|
var claims = result.UnwrapClaims();
|
|
|
|
Console.WriteLine(claims.Subject);
|
|
}
|
|
```
|
|
|
|
# Token expiration
|
|
|
|
```csharp
|
|
var token = tokens.Issue(
|
|
TokenDescriptor.Create()
|
|
.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"
|
|
);
|
|
|
|
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 = "api",
|
|
ValidAudience = "web",
|
|
BindingContext = "device-identifier"
|
|
};
|
|
```
|
|
|
|
# Revocation
|
|
|
|
You can integrate your own revocation storage:
|
|
|
|
```csharp
|
|
var options = new TokenValidationOptions
|
|
{
|
|
ValidIssuer = "api",
|
|
ValidAudience = "web",
|
|
|
|
RevocationCheck = async (tokenId, cancellationToken) =>
|
|
{
|
|
return await database.IsRevoked(tokenId);
|
|
}
|
|
};
|
|
```
|
|
|
|
# Replay protection for one-time-use tokens
|
|
|
|
Revocation answers "has someone explicitly blocked this token?" Replay protection
|
|
answers a different question: "has this exact token already been used once?" Use it
|
|
for password-reset links, email-verification links, and invitations - anything that
|
|
should only ever be redeemed a single time, even before it expires.
|
|
|
|
```csharp
|
|
var replayCache = new InMemoryReplayCache(); // register as a singleton in DI
|
|
|
|
var options = TokenValidationOptions.OneTimeUse(
|
|
issuer: "api",
|
|
tokenType: TokenTypeConstants.PasswordReset,
|
|
maxAge: TimeSpan.FromMinutes(15));
|
|
|
|
options.ReplayCache = replayCache;
|
|
|
|
var result = await tokens.ValidateAsync(token, options);
|
|
// Second call with the same token returns TokenResult.Replayed instead of Success.
|
|
```
|
|
|
|
`InMemoryReplayCache` is process-local and fine for a single instance. For multi-instance
|
|
deployments, implement `IReplayCache` against a shared store (Redis, or a database table
|
|
with a unique constraint on the token ID) so replay detection works across all instances.
|
|
|
|
Replay consumption only happens through `ValidateAsync`, since it has a side effect
|
|
(recording the token as used) - the synchronous `Validate` never touches the replay cache.
|
|
|
|
# Multiple audiences
|
|
|
|
Use `ValidAudiences` when the same access token needs to be accepted by more than one
|
|
downstream service:
|
|
|
|
```csharp
|
|
var options = TokenValidationOptions.AccessToken(
|
|
issuer: "api",
|
|
audiences: new[] { "service-a", "service-b", "service-c" });
|
|
```
|
|
|
|
The token is accepted if any one of its own audiences matches any one of the configured set.
|
|
|
|
# Audit and introspection hook
|
|
|
|
`OnValidated` is invoked for every validation attempt, success or failure, and is intended
|
|
for audit logging or metrics - not for authorization decisions:
|
|
|
|
```csharp
|
|
var options = TokenValidationOptions.AccessToken("api", "web");
|
|
|
|
options.OnValidated = e =>
|
|
{
|
|
logger.LogInformation("Token validation: {Result} sub={Subject}", e.Result, e.Subject);
|
|
};
|
|
```
|
|
|
|
`Subject` and `TokenId` on the event are only populated when the result is a success,
|
|
since claims are never trusted before the signature has verified.
|
|
|
|
# ASP.NET Core dependency injection
|
|
|
|
```csharp
|
|
builder.Services.AddSecureTokens();
|
|
```
|
|
|
|
or provide your own key store:
|
|
|
|
```csharp
|
|
builder.Services.AddSecureTokens(
|
|
store =>
|
|
{
|
|
return SigningKeyStore.FromKeys(
|
|
new[]
|
|
{
|
|
(1, secretKeyBytes)
|
|
});
|
|
});
|
|
```
|
|
|
|
To enable replay protection via DI:
|
|
|
|
```csharp
|
|
builder.Services.AddSecureTokenReplayProtection();
|
|
```
|
|
|
|
|
|
# Security design
|
|
|
|
The library separates cryptographic purposes:
|
|
|
|
```
|
|
Master Key
|
|
|
|
|
+-- Signing Key
|
|
|
|
|
+-- Encryption Key
|
|
|
|
|
+-- Context-specific keys
|
|
```
|
|
|
|
This prevents accidental key reuse between operations.
|
|
|
|
# Supported frameworks
|
|
|
|
- .NET Standard 2.0
|
|
- .NET Standard 2.1
|
|
- .NET Framework 4.8
|
|
- .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. |