Skip to content

Latest commit

 

History

History
645 lines (493 loc) · 17.8 KB

File metadata and controls

645 lines (493 loc) · 17.8 KB

API Reference

Full reference for JG.AuthKit -- JWT authentication and authorization for .NET APIs.


Table of Contents


Installation

dotnet add package JG.AuthKit

Targets .NET 8.0 and later.


Quick Start

1. Register services in Program.cs:

builder.Services.AddAuthKit(options =>
{
    options.Secret = builder.Configuration["Jwt:Secret"];
    options.Issuer = "my-api";
    options.Audience = "my-app";
    options.AccessTokenExpiry = TimeSpan.FromMinutes(15);
    options.RefreshTokenExpiry = TimeSpan.FromDays(30);
});

2. Add middleware:

var app = builder.Build();
app.UseAuthKit();   // Adds UseAuthentication() + UseAuthorization()
app.MapControllers();
app.Run();

3. Protect endpoints:

app.MapGet("/secure", [Authorize] () => "Hello, authenticated user!");
app.MapGet("/admin", [Authorize(Roles = "admin")] () => "Admin area");

4. Issue tokens from a login endpoint:

app.MapPost("/login", async (LoginRequest login, ITokenService tokenService) =>
{
    // Validate credentials (your logic here)
    var result = await tokenService.IssueTokenAsync(new TokenRequest
    {
        Subject = user.Id,
        Roles = user.Roles.ToList(),
    });

    return Results.Ok(new
    {
        result.AccessToken,
        result.RefreshToken,
        result.ExpiresAt,
        result.TokenType,
    });
});

5. Refresh tokens:

app.MapPost("/refresh", async (RefreshBody body, IRefreshTokenService refreshService) =>
{
    var result = await refreshService.RefreshAsync(new RefreshRequest
    {
        RefreshToken = body.RefreshToken,
    });

    return Results.Ok(new
    {
        result.AccessToken,
        result.RefreshToken,
        result.ExpiresAt,
    });
});

Configuration

AuthKitOptions

All options are configured via the AddAuthKit delegate:

builder.Services.AddAuthKit(options =>
{
    // --- Signing ---
    options.Secret = "your-256-bit-or-longer-secret-key";
    // OR provide a SecurityKey directly:
    // options.SigningKey = new RsaSecurityKey(rsa);
    options.SigningAlgorithm = "HS256";  // Default

    // --- Token Lifetime ---
    options.Issuer = "my-api";
    options.Audience = "my-app";
    options.AccessTokenExpiry = TimeSpan.FromMinutes(15);   // Default
    options.RefreshTokenExpiry = TimeSpan.FromDays(30);     // Default
    options.ClockSkew = TimeSpan.FromMinutes(1);            // Default

    // --- Features ---
    options.EnableRefreshTokenRotation = true;   // Default
    options.EnableTokenBlacklist = true;          // Default

    // --- Cleanup ---
    options.CleanupInterval = TimeSpan.FromMinutes(5);  // Default
    // Set to Timeout.InfiniteTimeSpan to disable background cleanup.
});
Property Type Default Description
Secret string? null HMAC secret for symmetric signing. At least 32 bytes UTF-8.
SigningKey SecurityKey? null Pre-configured key (RSA, ECDSA, etc.). Takes precedence over Secret.
SigningAlgorithm string "HS256" Algorithm for signing.
Issuer string? null Token iss claim. Enables issuer validation when set.
Audience string? null Token aud claim. Enables audience validation when set.
AccessTokenExpiry TimeSpan 15 min Access token lifetime.
RefreshTokenExpiry TimeSpan 30 days Refresh token lifetime.
ClockSkew TimeSpan 1 min Tolerance for token lifetime validation.
EnableRefreshTokenRotation bool true Issue refresh tokens with one-time-use rotation.
EnableTokenBlacklist bool true Allow individual token revocation.
SigningKeys List<SigningKeyDescriptor> [] Additional keys for key rotation.
CleanupInterval TimeSpan 5 min Background cleanup interval.

SigningKeyDescriptor

Used for key rotation. Add multiple keys to AuthKitOptions.SigningKeys:

options.SigningKeys.Add(new SigningKeyDescriptor
{
    KeyId = "key-2026-01",
    Key = new SymmetricSecurityKey(keyBytes),
    Algorithm = "HS256",
    ActiveFrom = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
    ActiveUntil = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero),
});
Property Type Description
KeyId string Unique key identifier (written to JWT kid header).
Key SecurityKey The cryptographic key.
Algorithm string Signing algorithm for this key.
ActiveFrom DateTimeOffset? When the key becomes active. null = immediately.
ActiveUntil DateTimeOffset? When the key stops being used for signing. null = no expiry.

The first key whose active window includes the current time is used for signing. During validation, all keys (including expired) are tried.


Token Issuance

ITokenService

Inject ITokenService to issue, validate, and revoke JWT tokens.

public interface ITokenService
{
    ValueTask<TokenResult> IssueTokenAsync(TokenRequest request, CancellationToken ct = default);
    ValueTask<TokenValidationOutcome> ValidateTokenAsync(string token, CancellationToken ct = default);
    ValueTask<bool> RevokeTokenAsync(string token, CancellationToken ct = default);
}

Issue a token:

var result = await tokenService.IssueTokenAsync(new TokenRequest
{
    Subject = "user-123",
    Roles = ["admin", "editor"],
    Claims = [new Claim("tenant", "acme")],
    CustomExpiry = TimeSpan.FromHours(1),   // Optional override
    Audience = "special-aud",               // Optional override
});

Validate a token:

var outcome = await tokenService.ValidateTokenAsync(tokenString);
if (outcome.IsValid)
{
    var userId = outcome.Principal!.FindFirst("sub")?.Value;
}
else
{
    Console.WriteLine(outcome.FailureReason);
}

Revoke a token:

bool revoked = await tokenService.RevokeTokenAsync(tokenString);

TokenRequest

Property Type Required Description
Subject string Yes User identifier (sub claim).
Roles IReadOnlyList<string>? No Roles to include as role claims.
Claims IReadOnlyList<Claim>? No Additional custom claims.
CustomExpiry TimeSpan? No Override default access token lifetime.
Audience string? No Override default audience.

TokenResult

Property Type Description
AccessToken string The signed JWT.
RefreshToken string? Refresh token (when rotation is enabled).
ExpiresAt DateTimeOffset When the access token expires.
TokenType string Always "Bearer".

Token Validation

TokenValidationOutcome

Returned by ITokenService.ValidateTokenAsync.

Property Type Description
IsValid bool Whether the token passed all checks.
Principal ClaimsPrincipal? The authenticated principal. null on failure.
FailureReason string? Human-readable failure reason. null on success.

The authentication middleware performs validation automatically. Use ValidateTokenAsync directly when you need manual control (e.g., WebSocket authentication, custom endpoints).


Refresh Tokens

IRefreshTokenService

public interface IRefreshTokenService
{
    ValueTask<TokenResult> RefreshAsync(RefreshRequest request, CancellationToken ct = default);
    ValueTask<bool> RevokeAsync(string refreshToken, CancellationToken ct = default);
    ValueTask<bool> RevokeFamilyAsync(string familyId, CancellationToken ct = default);
}

Rotation and reuse detection:

Refresh tokens are single-use. Each call to RefreshAsync consumes the current token and issues a new pair (access + refresh). If a consumed token is reused (indicating possible theft), the entire token family is revoked.

// Normal flow:
var result = await refreshService.RefreshAsync(new RefreshRequest
{
    RefreshToken = currentRefreshToken,
});
// Use result.AccessToken and result.RefreshToken going forward.

Revoke a user's session:

// Revoke by token (revokes the entire family):
await refreshService.RevokeAsync(refreshToken);

// Or revoke by family ID directly:
await refreshService.RevokeFamilyAsync(familyId);

RefreshRequest

Property Type Required Description
RefreshToken string Yes The raw refresh token from the client.
AdditionalClaims IReadOnlyList<Claim>? No Extra claims for the new access token.

RefreshToken

Internal model stored by IRefreshTokenStore. Contains:

Property Type Description
TokenHash string SHA-256 hash of the raw token.
FamilyId string Groups tokens from the same login.
Subject string The user identifier.
CreatedAt DateTimeOffset When created.
ExpiresAt DateTimeOffset When it expires.
IsConsumed bool Whether it has been used.
IsRevoked bool Whether it has been revoked.

Token Blacklisting

ITokenBlacklistStore

Implement this interface to provide custom blacklist storage (Redis, database, etc.):

public interface ITokenBlacklistStore
{
    ValueTask AddAsync(string tokenId, DateTimeOffset expiry, CancellationToken ct = default);
    ValueTask<bool> IsBlacklistedAsync(string tokenId, CancellationToken ct = default);
    ValueTask<int> CleanupExpiredAsync(CancellationToken ct = default);
}

InMemoryTokenBlacklistStore

The default implementation. Uses ConcurrentDictionary for lock-free concurrent access. Expired entries are cleaned up by the background service.

Suitable for single-instance deployments. For distributed scenarios, replace with your own implementation:

services.AddSingleton<ITokenBlacklistStore, RedisTokenBlacklistStore>();
services.AddAuthKit(options => { ... });

Register your custom store before calling AddAuthKit. The library uses TryAddSingleton, so it won't overwrite an existing registration.


Refresh Token Storage

IRefreshTokenStore

Implement for custom persistence:

public interface IRefreshTokenStore
{
    ValueTask StoreAsync(RefreshToken token, CancellationToken ct = default);
    ValueTask<RefreshToken?> GetAsync(string tokenHash, CancellationToken ct = default);
    ValueTask ConsumeAsync(string tokenHash, CancellationToken ct = default);
    ValueTask<bool> RevokeFamilyAsync(string familyId, CancellationToken ct = default);
    ValueTask<int> CleanupExpiredAsync(CancellationToken ct = default);
}

InMemoryRefreshTokenStore

Default in-memory implementation. Same registration pattern as the blacklist store.


Key Rotation

ISigningKeyProvider

Manages signing credentials and key resolution:

public interface ISigningKeyProvider
{
    SigningCredentials GetSigningCredentials();
    SecurityKey? ResolveKey(string kid);
    IReadOnlyList<SecurityKey> GetValidationKeys();
}

Key rotation example:

builder.Services.AddAuthKit(options =>
{
    options.Issuer = "my-api";

    // Old key (still accepted for validation, no longer used for signing)
    options.SigningKeys.Add(new SigningKeyDescriptor
    {
        KeyId = "key-2025-06",
        Key = new SymmetricSecurityKey(oldKeyBytes),
        Algorithm = "HS256",
        ActiveFrom = new DateTimeOffset(2025, 6, 1, 0, 0, 0, TimeSpan.Zero),
        ActiveUntil = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
    });

    // Current key
    options.SigningKeys.Add(new SigningKeyDescriptor
    {
        KeyId = "key-2026-01",
        Key = new SymmetricSecurityKey(currentKeyBytes),
        Algorithm = "HS256",
        ActiveFrom = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
    });
});

Tokens signed with the old key will still validate. New tokens are signed with the current key.


Claim Transformation

IClaimTransformer

Register one or more transformers to enrich claims during token issuance:

public class DatabaseRoleTransformer : IClaimTransformer
{
    private readonly IUserRoleRepository _roles;

    public DatabaseRoleTransformer(IUserRoleRepository roles) => _roles = roles;

    public async ValueTask<IReadOnlyList<Claim>> TransformAsync(
        IReadOnlyList<Claim> claims, CancellationToken cancellationToken)
    {
        var sub = claims.FirstOrDefault(c => c.Type == "sub")?.Value;
        if (sub is null) return claims;

        var roles = await _roles.GetRolesAsync(sub, cancellationToken);
        var result = new List<Claim>(claims);
        foreach (var role in roles)
            result.Add(new Claim(ClaimTypes.Role, role));
        return result;
    }
}

Register in DI:

builder.Services.AddSingleton<IClaimTransformer, DatabaseRoleTransformer>();

Transformers run in registration order during IssueTokenAsync.


Authorization

RoleRequirement

Policy-based role authorization:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.AddRequirements(new RoleRequirement("admin")));

    options.AddPolicy("EditorOrAdmin", policy =>
        policy.AddRequirements(new RoleRequirement("admin", "editor")));
});

The handler checks ClaimTypes.Role claims. Case-insensitive matching.

ClaimRequirement

Policy-based claim authorization:

builder.Services.AddAuthorization(options =>
{
    // Require the "tenant" claim (any value):
    options.AddPolicy("HasTenant", policy =>
        policy.AddRequirements(new ClaimRequirement("tenant")));

    // Require specific values:
    options.AddPolicy("EngineeringOnly", policy =>
        policy.AddRequirements(new ClaimRequirement("department", "engineering", "devops")));
});

Middleware

UseAuthKit

Convenience method that adds both authentication and authorization middleware:

app.UseAuthKit();
// Equivalent to:
// app.UseAuthentication();
// app.UseAuthorization();

The AuthKit authentication handler extracts the Bearer token from the Authorization header, validates it using ITokenService, and populates HttpContext.User.

UseAuthKitTokenBlacklist

Standalone blacklist middleware for custom auth setups. Only needed if you are not using the AuthKit authentication handler:

app.UseAuthentication();         // Your custom auth
app.UseAuthKitTokenBlacklist();  // Check blacklist
app.UseAuthorization();

Replacing Default Stores

Register your implementations before AddAuthKit:

// Redis-backed stores
builder.Services.AddSingleton<ITokenBlacklistStore, RedisTokenBlacklistStore>();
builder.Services.AddSingleton<IRefreshTokenStore, RedisRefreshTokenStore>();

// AuthKit will not overwrite these registrations
builder.Services.AddAuthKit(options => { ... });

The default in-memory stores use ConcurrentDictionary and are suitable for single-instance deployments, development, and testing.


Advanced Scenarios

RSA Signing

var rsa = RSA.Create(2048);
builder.Services.AddAuthKit(options =>
{
    options.SigningKey = new RsaSecurityKey(rsa);
    options.SigningAlgorithm = "RS256";
    options.Issuer = "my-api";
});

ECDSA Signing

var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
builder.Services.AddAuthKit(options =>
{
    options.SigningKey = new ECDsaSecurityKey(ecdsa);
    options.SigningAlgorithm = "ES256";
    options.Issuer = "my-api";
});

WebSocket Authentication

app.MapGet("/ws", async (HttpContext ctx, ITokenService tokenService) =>
{
    var token = ctx.Request.Query["access_token"].ToString();
    var outcome = await tokenService.ValidateTokenAsync(token);

    if (!outcome.IsValid)
    {
        ctx.Response.StatusCode = 401;
        return;
    }

    using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
    // Handle WebSocket with outcome.Principal
});

Disabling Refresh Tokens

builder.Services.AddAuthKit(options =>
{
    options.Secret = secret;
    options.Issuer = "my-api";
    options.EnableRefreshTokenRotation = false;
});
// IssueTokenAsync will return null for RefreshToken

Disabling Token Blacklisting

builder.Services.AddAuthKit(options =>
{
    options.Secret = secret;
    options.Issuer = "my-api";
    options.EnableTokenBlacklist = false;
});
// RevokeTokenAsync will always return false

Disabling Background Cleanup

builder.Services.AddAuthKit(options =>
{
    options.Secret = secret;
    options.Issuer = "my-api";
    options.CleanupInterval = Timeout.InfiniteTimeSpan;
});