# EonaCat.SecureToken **A cryptographically superior alternative to JWT for .NET 8+** ## Installation ```bash dotnet add package SecureToken ``` ## Quick Start ### 1. Register with DI (ASP.NET Core) ```csharp // Program.cs using SecureToken.Extensions; 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)]); }); ``` For development / testing, a random ephemeral key is fine: ```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( TokenDescriptor.Create() .ForSubject(user.Id) .IssuedBy("my-api") .ForAudience("my-api") .BoundTo(clientIpAddress) // <-- context binding ); // Validate - must supply the same context var result = await _tokens.ValidateAsync(token, new TokenValidationOptions { ValidIssuer = "my-api", ValidAudience = "my-api", RequiredTokenType = TokenTypeConstants.Access, BindingContext = Request.HttpContext.Connection.RemoteIpAddress?.ToString(), }); ``` 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. ```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); ``` Each token embeds the key generation it was signed with. The verifier automatically picks the right key. ## Revocation Plug in any store - Redis, database, or in-memory: ```csharp // Example: Redis-backed revocation var options = new TokenValidationOptions { ValidIssuer = "my-api", ValidAudience = "my-api", RequiredTokenType = TokenTypeConstants.Access, 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); ``` ## 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 var options = new TokenValidationOptions { // Issuer the token must have been issued by ValidIssuer = "my-api", // 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), }; ``` ## Inspecting Tokens (Debug Only) ```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}"); ``` ## Token Format ``` stv1.{base64url(payload)}.{base64url(signature)} ``` - `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. 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. ## Security Properties - **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.