Added .net standard 2.0 and .net framework 4.8 support
This commit is contained in:
@@ -1,343 +1,271 @@
|
||||
# EonaCat.SecureToken
|
||||
|
||||
**A cryptographically superior alternative to JWT for .NET 8+**
|
||||
**Secure, modern token library for .NET with key rotation, signing isolation, validation rules, and .NET Standard support.**
|
||||
|
||||
EonaCat.SecureToken provides a safer alternative to rolling your own authentication tokens. It focuses on:
|
||||
|
||||
- Strong cryptographic signing
|
||||
- Versioned key management
|
||||
- Token lifecycle validation
|
||||
- Refresh/access token separation
|
||||
- Extensible claims
|
||||
- API-friendly validation results
|
||||
- `.NET Standard 2.0` compatibility
|
||||
|
||||
## Features
|
||||
|
||||
### Cryptographic protection
|
||||
|
||||
- HMAC based token signing
|
||||
- HKDF derived context-specific keys
|
||||
- Constant-time signature verification
|
||||
- Tamper detection
|
||||
- Strong random key generation
|
||||
|
||||
### Key rotation
|
||||
|
||||
Rotate signing keys without invalidating existing tokens.
|
||||
|
||||
Example:
|
||||
|
||||
```csharp
|
||||
var store = SigningKeyStore.CreateNew();
|
||||
|
||||
var service = new TokenService(store);
|
||||
|
||||
var oldToken = service.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject("user-123")
|
||||
.IssuedBy("my-api")
|
||||
.ForAudience("mobile")
|
||||
);
|
||||
|
||||
// Rotate keys
|
||||
store.Rotate();
|
||||
|
||||
// New tokens use the new key
|
||||
var newToken = service.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject("user-456")
|
||||
.IssuedBy("my-api")
|
||||
.ForAudience("mobile")
|
||||
);
|
||||
|
||||
// Old token still validates
|
||||
service.Validate(
|
||||
oldToken,
|
||||
TokenValidationOptions.AccessToken("my-api", "mobile")
|
||||
);
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Install from NuGet:
|
||||
|
||||
```bash
|
||||
dotnet add package SecureToken
|
||||
dotnet add package EonaCat.SecureToken
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
# Quick Start
|
||||
|
||||
### 1. Register with DI (ASP.NET Core)
|
||||
## Create a token service
|
||||
|
||||
```csharp
|
||||
// Program.cs
|
||||
using SecureToken.Extensions;
|
||||
using EonaCat.SecureToken.Core;
|
||||
using EonaCat.SecureToken.Cryptography;
|
||||
|
||||
builder.Services.AddSecureTokens(sp =>
|
||||
{
|
||||
// Load your key material from environment / secrets manager
|
||||
var keyBytes = Convert.FromBase64String(Environment.GetEnvironmentVariable("TOKEN_KEY")!);
|
||||
return SigningKeyStore.FromKeys([(1, keyBytes)]);
|
||||
});
|
||||
var keys = SigningKeyStore.CreateNew();
|
||||
|
||||
var tokens = new TokenService(keys);
|
||||
```
|
||||
|
||||
For development / testing, a random ephemeral key is fine:
|
||||
## Issue an access token
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSecureTokens(); // random key, resets on restart
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 2. Issue a Token
|
||||
|
||||
```csharp
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly ITokenService _tokens;
|
||||
|
||||
public AuthController(ITokenService tokens) => _tokens = tokens;
|
||||
|
||||
[HttpPost("login")]
|
||||
public IActionResult Login([FromBody] LoginRequest req)
|
||||
{
|
||||
// ... verify credentials ...
|
||||
|
||||
var token = _tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject(user.Id)
|
||||
.IssuedBy("my-api")
|
||||
.ForAudience("my-api")
|
||||
.WithRole("user")
|
||||
.WithClaim("email", user.Email)
|
||||
.ExpiresIn(minutes: 15)
|
||||
);
|
||||
|
||||
return Ok(new { token });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 3. Validate a Token
|
||||
|
||||
```csharp
|
||||
var result = await _tokens.ValidateAsync(
|
||||
rawToken,
|
||||
TokenValidationOptions.AccessToken(issuer: "my-api", audience: "my-api")
|
||||
);
|
||||
|
||||
// Pattern-match the result - no exceptions thrown
|
||||
switch (result)
|
||||
{
|
||||
case TokenResult.Success s:
|
||||
var userId = s.Claims.Subject;
|
||||
var roles = s.Claims.Roles;
|
||||
break;
|
||||
|
||||
case TokenResult.Expired e:
|
||||
return Unauthorized($"Token expired at {e.ExpiredAt}");
|
||||
|
||||
case TokenResult.InvalidSignature:
|
||||
return Unauthorized("Token signature is invalid.");
|
||||
|
||||
case TokenResult.WrongTokenType w:
|
||||
return Unauthorized($"Expected {w.Expected}, got {w.Actual}");
|
||||
|
||||
case TokenResult.Revoked r:
|
||||
return Unauthorized($"Token {r.TokenId} has been revoked.");
|
||||
|
||||
default:
|
||||
return Unauthorized("Token validation failed.");
|
||||
}
|
||||
```
|
||||
|
||||
Or use the match helper:
|
||||
|
||||
```csharp
|
||||
return result.Match(
|
||||
onSuccess: s => Ok(new { s.Claims.Subject }),
|
||||
onFailure: err => Unauthorized(err.ToString())
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Token Pairs (Access + Refresh)
|
||||
|
||||
Issue a short-lived access token and a long-lived refresh token in one call:
|
||||
|
||||
```csharp
|
||||
var pair = _tokens.IssueTokenPair(
|
||||
subject: user.Id,
|
||||
issuer: "my-api",
|
||||
audience: "my-api",
|
||||
roles: ["admin"],
|
||||
claims: [new("plan", "pro")],
|
||||
accessTokenLifetime: TimeSpan.FromMinutes(15),
|
||||
refreshTokenLifetime: TimeSpan.FromDays(30)
|
||||
);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
accessToken: pair.AccessToken,
|
||||
refreshToken: pair.RefreshToken,
|
||||
expiresAt: pair.AccessTokenExpiry,
|
||||
});
|
||||
```
|
||||
|
||||
Refresh tokens are **type-tagged** - they cannot be used where an access token is expected, and vice versa.
|
||||
|
||||
```csharp
|
||||
// Refreshing
|
||||
var refreshResult = await _tokens.ValidateAsync(
|
||||
refreshToken,
|
||||
TokenValidationOptions.RefreshToken("my-api")
|
||||
);
|
||||
|
||||
if (refreshResult is TokenResult.Success s)
|
||||
{
|
||||
var newPair = _tokens.IssueTokenPair(s.Claims.Subject, "my-api", "my-api");
|
||||
return Ok(newPair);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Context Binding
|
||||
|
||||
Bind a token to a client context (IP address, device fingerprint, TLS channel hash, etc.).
|
||||
A stolen token from another context will fail validation.
|
||||
|
||||
```csharp
|
||||
// Issue - bind to the client's IP
|
||||
var token = _tokens.Issue(
|
||||
var token = tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject(user.Id)
|
||||
.IssuedBy("my-api")
|
||||
.ForAudience("my-api")
|
||||
.BoundTo(clientIpAddress) // <-- context binding
|
||||
.ForSubject("user-123")
|
||||
.IssuedBy("my-service")
|
||||
.ForAudience("api")
|
||||
.WithRole("admin")
|
||||
.WithClaim("email", "user@example.com")
|
||||
);
|
||||
```
|
||||
|
||||
The token contains:
|
||||
|
||||
- Subject
|
||||
- Issuer
|
||||
- Audience
|
||||
- Roles
|
||||
- Custom claims
|
||||
- Token ID
|
||||
- Expiration
|
||||
- Key generation information
|
||||
|
||||
|
||||
## Validate a token
|
||||
|
||||
```csharp
|
||||
var result = tokens.Validate(
|
||||
token,
|
||||
TokenValidationOptions.AccessToken(
|
||||
issuer: "my-service",
|
||||
audience: "api"
|
||||
)
|
||||
);
|
||||
|
||||
// Validate - must supply the same context
|
||||
var result = await _tokens.ValidateAsync(token, new TokenValidationOptions
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
ValidIssuer = "my-api",
|
||||
ValidAudience = "my-api",
|
||||
RequiredTokenType = TokenTypeConstants.Access,
|
||||
BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
});
|
||||
var claims = result.UnwrapClaims();
|
||||
|
||||
Console.WriteLine(claims.Subject);
|
||||
}
|
||||
```
|
||||
|
||||
If the IP doesn't match, validation returns `TokenResult.BindingMismatch`.
|
||||
|
||||
|
||||
|
||||
## Key Rotation
|
||||
|
||||
Keys are versioned. Old tokens remain valid after rotation until they expire naturally.
|
||||
# Token expiration
|
||||
|
||||
```csharp
|
||||
// Inject SigningKeyStore and rotate
|
||||
var newGeneration = _keyStore.Rotate();
|
||||
|
||||
// Or supply new key material from your KMS
|
||||
var newGeneration = _keyStore.Rotate(myKmsBytes);
|
||||
|
||||
// Prune old generation once all tokens from it have expired
|
||||
_keyStore.PruneGeneration(oldGeneration);
|
||||
var token = tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject("user-1")
|
||||
.IssuedBy("api")
|
||||
.ForAudience("mobile")
|
||||
.WithLifetime(TimeSpan.FromMinutes(15))
|
||||
);
|
||||
```
|
||||
|
||||
Each token embeds the key generation it was signed with. The verifier automatically picks the right key.
|
||||
Expired tokens are automatically rejected.
|
||||
|
||||
# Refresh tokens
|
||||
|
||||
|
||||
## Revocation
|
||||
|
||||
Plug in any store - Redis, database, or in-memory:
|
||||
Create a refresh token:
|
||||
|
||||
```csharp
|
||||
// Example: Redis-backed revocation
|
||||
var options = new TokenValidationOptions
|
||||
var pair = tokens.IssueTokenPair(
|
||||
"user-1",
|
||||
"api",
|
||||
"mobile"
|
||||
);
|
||||
|
||||
Console.WriteLine(pair.AccessToken);
|
||||
Console.WriteLine(pair.RefreshToken);
|
||||
```
|
||||
|
||||
Validate separately:
|
||||
|
||||
```csharp
|
||||
tokens.Validate(
|
||||
pair.RefreshToken,
|
||||
TokenValidationOptions.RefreshToken("api")
|
||||
);
|
||||
```
|
||||
|
||||
Refresh tokens cannot be used as access tokens.
|
||||
|
||||
# Token binding
|
||||
|
||||
Bind tokens to a context such as a device or session:
|
||||
|
||||
```csharp
|
||||
var token = tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject("user-1")
|
||||
.IssuedBy("api")
|
||||
.ForAudience("web")
|
||||
.BoundTo("device-identifier")
|
||||
);
|
||||
```
|
||||
|
||||
Validation:
|
||||
|
||||
```csharp
|
||||
new TokenValidationOptions
|
||||
{
|
||||
ValidIssuer = "my-api",
|
||||
ValidAudience = "my-api",
|
||||
RequiredTokenType = TokenTypeConstants.Access,
|
||||
RevocationCheck = async (tokenId, ct) =>
|
||||
{
|
||||
return await _redis.KeyExistsAsync($"revoked:{tokenId}");
|
||||
}
|
||||
ValidIssuer = "api",
|
||||
ValidAudience = "web",
|
||||
BindingContext = "device-identifier"
|
||||
};
|
||||
|
||||
// To revoke a token:
|
||||
var claims = _tokens.Inspect(tokenString);
|
||||
await _redis.StringSetAsync($"revoked:{claims!.TokenId}", "1", claims.ExpiresAt - DateTimeOffset.UtcNow);
|
||||
```
|
||||
|
||||
# Revocation
|
||||
|
||||
|
||||
## Custom Token Types
|
||||
|
||||
Prevent tokens from being used across different purposes:
|
||||
|
||||
```csharp
|
||||
// Issue an invitation token
|
||||
var inviteToken = _tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject(inviteeEmail)
|
||||
.IssuedBy("my-api")
|
||||
.OfType(TokenTypeConstants.Invitation) // or your own string, e.g. "workspace-invite+v1"
|
||||
.WithClaim("workspaceId", workspaceId)
|
||||
.ExpiresIn(minutes: 60 * 48) // 48 hours
|
||||
);
|
||||
|
||||
// Validate - only accepts invitation tokens
|
||||
var result = svc.Validate(token, new TokenValidationOptions
|
||||
{
|
||||
ValidIssuer = "my-api",
|
||||
RequiredTokenType = TokenTypeConstants.Invitation,
|
||||
});
|
||||
```
|
||||
|
||||
Built-in type constants in `TokenTypeConstants`:
|
||||
|
||||
| Constant | Value |
|
||||
|||
|
||||
| `Access` | `at+secure` |
|
||||
| `Refresh` | `rt+secure` |
|
||||
| `ServiceAccount` | `sa+secure` |
|
||||
| `Invitation` | `inv+secure` |
|
||||
| `PasswordReset` | `pwr+secure` |
|
||||
| `EmailVerification` | `ev+secure` |
|
||||
|
||||
|
||||
|
||||
## Service Account Tokens
|
||||
|
||||
```csharp
|
||||
var saToken = _tokens.Issue(
|
||||
TokenDescriptor.Create()
|
||||
.ForSubject("svc-worker-1")
|
||||
.IssuedBy("my-api")
|
||||
.ForAudience("internal-queue")
|
||||
.AsServiceAccount()
|
||||
.WithRole("queue-publisher")
|
||||
.WithLifetime(TimeSpan.FromDays(365))
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Full Validation Options Reference
|
||||
You can integrate your own revocation storage:
|
||||
|
||||
```csharp
|
||||
var options = new TokenValidationOptions
|
||||
{
|
||||
// Issuer the token must have been issued by
|
||||
ValidIssuer = "my-api",
|
||||
ValidIssuer = "api",
|
||||
ValidAudience = "web",
|
||||
|
||||
// Audience the token must include
|
||||
ValidAudience = "my-api",
|
||||
|
||||
// Token type that must match exactly
|
||||
RequiredTokenType = TokenTypeConstants.Access,
|
||||
|
||||
// Context binding (IP, device hash, etc.)
|
||||
BindingContext = clientIp,
|
||||
|
||||
// Clock skew tolerance (default: 30 seconds)
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
|
||||
// Toggle individual checks
|
||||
ValidateExpiry = true,
|
||||
ValidateNotBefore = true,
|
||||
|
||||
// Async revocation check - skip for sync validation
|
||||
RevocationCheck = async (tokenId, ct) =>
|
||||
await _revokedIds.ContainsAsync(tokenId, ct),
|
||||
RevocationCheck = async (tokenId, cancellationToken) =>
|
||||
{
|
||||
return await database.IsRevoked(tokenId);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Inspecting Tokens (Debug Only)
|
||||
# ASP.NET Core dependency injection
|
||||
|
||||
```csharp
|
||||
// No signature check - for diagnostics, never for authorization
|
||||
var claims = _tokens.Inspect(rawToken);
|
||||
|
||||
Console.WriteLine($"Subject: {claims?.Subject}");
|
||||
Console.WriteLine($"Issued: {claims?.IssuedAt}");
|
||||
Console.WriteLine($"Expires: {claims?.ExpiresAt}");
|
||||
Console.WriteLine($"KeyGen: {claims?.KeyGeneration}");
|
||||
builder.Services.AddSecureTokens();
|
||||
```
|
||||
|
||||
or provide your own key store:
|
||||
|
||||
|
||||
## Token Format
|
||||
|
||||
```
|
||||
stv1.{base64url(payload)}.{base64url(signature)}
|
||||
```csharp
|
||||
builder.Services.AddSecureTokens(
|
||||
store =>
|
||||
{
|
||||
return SigningKeyStore.FromKeys(
|
||||
new[]
|
||||
{
|
||||
(1, secretKeyBytes)
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- `stv1` - version prefix, never changes algorithm.
|
||||
- `payload` - compact binary-encoded claims (not JSON).
|
||||
- `signature` - HMAC-SHA256 over the payload using an HKDF-derived sub-key.
|
||||
# Security design
|
||||
|
||||
The sub-key is derived from the master key using HKDF with the **token type as context**, so a signing key used for access tokens cannot verify refresh tokens even if an attacker switches the type tag.
|
||||
The library separates cryptographic purposes:
|
||||
|
||||
```
|
||||
Master Key
|
||||
|
|
||||
+-- Signing Key
|
||||
|
|
||||
+-- Encryption Key
|
||||
|
|
||||
+-- Context-specific keys
|
||||
```
|
||||
|
||||
This prevents accidental key reuse between operations.
|
||||
|
||||
## Security Properties
|
||||
# Supported frameworks
|
||||
|
||||
- **No algorithm field in token** - the verifier always decides the algorithm.
|
||||
- **Binary payload** - no JSON parser, no prototype pollution, no ambiguous number types.
|
||||
- **Type-segregated sub-keys** - HKDF derives a separate key per token type.
|
||||
- **Constant-time comparison** - signature verification uses `CryptographicOperations.FixedTimeEquals`.
|
||||
- **Typed results, no exceptions** - validation never throws; every failure is a named case.
|
||||
- **Context binding** - tokens can be tied to an IP, device, or TLS channel binding.
|
||||
- **Versioned key rotation** - rotate keys without invalidating unexpired tokens.
|
||||
- .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