TechTeaStudio.Auth 0.5.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package TechTeaStudio.Auth --version 0.5.0
                    
NuGet\Install-Package TechTeaStudio.Auth -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TechTeaStudio.Auth" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TechTeaStudio.Auth" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="TechTeaStudio.Auth" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TechTeaStudio.Auth --version 0.5.0
                    
#r "nuget: TechTeaStudio.Auth, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TechTeaStudio.Auth@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TechTeaStudio.Auth&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=TechTeaStudio.Auth&version=0.5.0
                    
Install as a Cake Tool

<p align="center"> <img src="https://raw.githubusercontent.com/TechTeaStudio/Auth/product/icon.png" alt="TechTeaStudio.Auth logo" width="160" /> </p>

<h1 align="center">TechTeaStudio.Auth</h1>

<p align="center"> Drop-in authentication primitives for .NET. JWT, PBKDF2 password hashing, refresh-token rotation, multi-app claim profiles, lockout, audit, 2FA, and one-line ASP.NET Core wire-up — without pulling in a full identity framework. </p>

<p align="center"> <a href="https://www.nuget.org/packages/TechTeaStudio.Auth"><img alt="NuGet" src="https://img.shields.io/nuget/v/TechTeaStudio.Auth.svg?logo=nuget&label=NuGet" /></a> <a href="https://www.nuget.org/packages/TechTeaStudio.Auth"><img alt="Downloads" src="https://img.shields.io/nuget/dt/TechTeaStudio.Auth.svg?logo=nuget&label=Downloads" /></a> <img alt=".NET" src="https://img.shields.io/badge/.NET-6.0%20%7C%208.0%20%7C%209.0%20%7C%2010.0-512BD4?logo=dotnet&logoColor=white" /> <a href="https://github.com/TechTeaStudio/Auth/actions/workflows/dotnet.yml"><img alt="Build" src="https://img.shields.io/github/actions/workflow/status/TechTeaStudio/Auth/dotnet.yml?branch=product&logo=github&label=build" /></a> <a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-blue.svg" /></a> </p>

Overview

TechTeaStudio.Auth solves the handful of problems every .NET service re-implements: issuing and validating JWTs, hashing passwords the way the year you're reading this expects, rotating refresh tokens without leaking them, and wiring all of it into ASP.NET Core without a 200-line Program.cs ceremony. It does this with a small, focused public surface, four target frameworks, and no opinion about what the rest of your app looks like.

The package was extracted from the Hyperion Omni Client — which already shipped its own JWT + PBKDF2 stack — so that Hyperion, Pello, and any future TTS app can share one hardened implementation instead of three drifting copies.

When to reach for it

You want this library when you need first-party authentication primitives in a .NET service and you'd rather not glue together five Microsoft.* packages plus a hand-written hasher. Concretely, that means:

  • A web API or BFF you own, where users authenticate against your own user store.
  • A small-to-medium app that doesn't need a full IdentityServer / OpenIddict footprint.
  • A codebase that values explicit DI registration over magic conventions.
  • You're already on Hyperion or Pello and want to stop duplicating the auth stack.

You probably don't want this library when you need full OAuth2 / OIDC server semantics (use OpenIddict or Duende IdentityServer), when you're delegating auth entirely to an external IdP (use the matching Microsoft.AspNetCore.Authentication.* package directly), or when you need ASP.NET Core Identity's UI scaffolding for users / roles / claims.

How it compares

Library / Approach What it gives you Footprint Password hashing Refresh tokens Multi-app claim profiles
TechTeaStudio.Auth JWT + password hashing + refresh tokens + lockout + audit + 2FA + middleware Small (~one package) PBKDF2-SHA256, sane defaults Built-in, single-use, rotated First-class
ASP.NET Core Identity Full user/role store + UI scaffolding Large (EF Core + UI + scaffolding) PBKDF2 (configurable) Via separate add-on Manual claims transformer
OpenIddict / Duende IdentityServer OAuth2 / OIDC server Large (server-side flows, endpoints) Delegated to your store First-class Via scope/claim mapping
Microsoft.AspNetCore.Authentication.JwtBearer (raw) JWT validation only Tiny None None Manual
Roll your own Whatever you write Whatever you write Whatever you remember The bug you'll ship The mess you'll inherit

The honest pitch: TechTeaStudio.Auth sits between "raw JwtBearer + a hand-written hasher" and "a full identity framework". If you're about to write your fifth PBKDF2 helper and then realise you also need refresh-token rotation and a way to keep two apps' claim shapes from diverging, this is the package that already did all of that.

Install

dotnet add package TechTeaStudio.Auth

Or pin a specific version in .csproj:

<PackageReference Include="TechTeaStudio.Auth" Version="0.2.0" />

Quick start

ASP.NET Core wire-up

using TechTeaStudio.Auth.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddTechTeaStudioAuth(builder.Configuration);

var app = builder.Build();
app.UseSecurityHeaders();   // optional
app.UseAuthentication();
app.UseAuthorization();
// appsettings.json
{
  "Auth": {
    "Jwt": {
      "SecretKey": "<32-byte-secret-or-longer>",
      "Issuer": "https://api.example.com",
      "Audience": "example-clients",
      "TokenLifetime": "00:30:00"
    },
    "RefreshTokens": { "Lifetime": "7.00:00:00" },
    "Lockout": { "MaxFailedAttempts": 5, "Duration": "00:15:00" }
  }
}

AddTechTeaStudioAuth registers ITokenProvider, ITokenReader, IPasswordHasher, IRefreshTokenStore (in-memory by default), RefreshTokenService, IRefreshClaimsResolver, ILoginAttemptTracker, IRevokedTokenStore, IAuthAuditLogger (null sink), the JWT bearer scheme, ASP.NET Core authorization, the RefreshTokenCleanupService + RevokedTokenCleanupService background workers, and startup validation of AuthOptions.

All store / tracker / logger registrations use TryAdd*, so prior registrations win. The clean way to swap a default is the fluent builder returned by AddTechTeaStudioAuth(...):

builder.Services.AddTechTeaStudioAuth(builder.Configuration)
    .UseRefreshTokenStore<EfCoreRefreshTokenStore<MyDbContext>>()
    .UseLoginAttemptTracker<RedisLoginAttemptTracker>()
    .UseAuthAuditLogger<MyDbAuthAuditLogger>()
    .UseClaimsProfile<MyAppClaimsProfile>()
    .UseRefreshClaimsResolver<MyClaimsResolver>();

Issuing a token pair

public sealed class LoginEndpoint
{
    private readonly IPasswordHasher _passwords;
    private readonly RefreshTokenService _refresh;
    private readonly IUserRepository _users;

    public LoginEndpoint(IPasswordHasher p, RefreshTokenService r, IUserRepository u)
        => (_passwords, _refresh, _users) = (p, r, u);

    public async Task<IResult> Handle(LoginRequest req, CancellationToken ct)
    {
        var user = await _users.FindAsync(req.Email, ct);
        if (user is null || !_passwords.Verify(user.PasswordHash, req.Password))
            return Results.Unauthorized();

        var claims = new[]
        {
            new Claim(AuthClaims.Username, user.Username),
            new Claim(AuthClaims.Email, user.Email),
        }
        .Concat(user.Roles.Select(r => new Claim(AuthClaims.Role, r)));

        var pair = await _refresh.IssueAsync(user.Id, claims, ct);

        return Results.Ok(new { pair.AccessToken, pair.RefreshToken, pair.RefreshTokenExpiresAt });
    }
}

Rotating refresh tokens

// Caller knows the claims to embed:
var pair = await _refresh.RotateAsync(req.RefreshToken, claims, ct);

// Or register an IRefreshClaimsResolver once and use the no-claims overload:
var pair = await _refresh.RotateAsync(req.RefreshToken, ct);

return pair is null ? Results.Unauthorized() : Results.Ok(pair);

Refresh tokens are single-use. A successful rotation revokes the presented token and emits a fresh one. Presenting an already-revoked token revokes the whole rotation chain when AuthOptions.RefreshTokens.RevokeChainOnReuse is on (default).

Hashing passwords

var hash    = _passwords.Hash("correct horse battery staple");
var matches = _passwords.Verify(hash, "correct horse battery staple");   // (hashed, provided)

PBKDF2-SHA256 · 600 000 iterations · 16-byte salt · 32-byte digest · constant-time verify.

One-shot signed tokens

// password reset
var token = _resetTokens.Generate(user.Id);          // 30-min default lifetime
var r     = await _resetTokens.ValidateAsync(token); // one-shot — replay returns Success=false

// email confirmation
var token = _emailTokens.Generate(user.Id, user.Email);  // 24-hour default
var r     = await _emailTokens.ValidateAsync(token);

Single-use is enforced via the IRevokedTokenStore deny-list — the jti of the token is revoked on the first successful validation.

Two-factor (TOTP + recovery codes)

var secret        = RandomNumberGenerator.GetBytes(20);
var base32Secret  = OtpAuthUri.ToBase32(secret);
var provisioning  = OtpAuthUri.Build("MyApp", user.Email, base32Secret); // → otpauth://totp/...
var recoveryCodes = RecoveryCodeService.Generate();                       // 10 × 8-char codes

// Verify what the user typed from Google/Microsoft Authenticator:
var ok = TotpValidator.Validate(secret, typedCode, DateTimeOffset.UtcNow);

API-key authentication scheme

services.AddSingleton<IApiKeyStore>(new FuncApiKeyStore((raw, ct) =>
    Task.FromResult(LookUp(raw))));
services.AddAuthentication().AddTechTeaStudioApiKey();

app.MapGet("/internal/hooks", () => "ok")
   .RequireAuthorization();

Reads X-Api-Key by default; also accepts Authorization: ApiKey <key> when the option is on.

Multi-app claim profiles

Different apps publish different claim shapes. The library defines the IClaimsProfile abstraction; each app implements its own profile and registers it via DI:

public sealed class MyAppClaimsProfile : IClaimsProfile
{
    public string Name => "MyApp";
    public IEnumerable<Claim> BuildClaims(ClaimsBuilderInput input)
    {
        if (!string.IsNullOrEmpty(input.UserId)) yield return new Claim(AuthClaims.Subject, input.UserId);
        if (!string.IsNullOrEmpty(input.Email))  yield return new Claim(AuthClaims.Email, input.Email);
        if (input.Roles is not null)
            foreach (var r in input.Roles) yield return new Claim(AuthClaims.Role, r);
    }
}

builder.Services.AddTechTeaStudioAuth(builder.Configuration)
    .UseClaimsProfile<MyAppClaimsProfile>();

The library deliberately does not ship product-specific profiles — every consuming app owns its claim shape and the library stays generic.

⚠️ In-memory defaults are NOT for multi-instance production

The defaults registered by AddTechTeaStudioAuth() are designed for fast dev / single-instance scenarios:

  • InMemoryRefreshTokenStore — lost on restart.
  • InMemoryLoginAttemptTracker — brute-forcer hitting different instances behind a load balancer bypasses lockout entirely.
  • InMemoryRevokedTokenStore — JTI revoked on instance A is unknown to instance B; a stolen access token keeps working everywhere else until natural expiry.

For any multi-instance production deployment, replace via the builder:

builder.Services.AddTechTeaStudioAuth(builder.Configuration)
    .UseRefreshTokenStore<EfCoreRefreshTokenStore<MyDbContext>>()   // TechTeaStudio.Auth.EFCore
    .UseLoginAttemptTracker<RedisLoginAttemptTracker>();             // TechTeaStudio.Auth.Redis

⚠️ TechTeaStudio.Auth.Redis is in early-stage (v0.5) — passes the contract test kit, but RevokeAsync(Guid) walks every key (O(N)) and there are no production benchmarks yet. Suitable for a starting point; will be hardened with a reverse-index in v0.6+.

Security defaults

Concern Default Knob
Password hashing PBKDF2-SHA256, 600 000 iterations, 16-byte salt (algorithm version is fixed; iteration count is fixed in 0.x)
Access token lifetime 30 minutes AuthOptions.Jwt.TokenLifetime
Refresh token lifetime 7 days AuthOptions.RefreshTokens.Lifetime
Refresh token reuse Single-use, rotated; replay revokes the chain AuthOptions.RefreshTokens.RevokeChainOnReuse
Signing algorithm HS256 (RS256, ES256 also supported via Jwt.Signing.Keys) AuthOptions.Jwt.Signing
Clock skew 5 minutes AuthOptions.Jwt.ClockSkew
Account lockout 5 failed attempts → 15-minute lockout AuthOptions.Lockout.MaxFailedAttempts, AuthOptions.Lockout.Duration
Refresh-token cleanup every 1 hour AuthOptions.RefreshTokens.CleanupInterval

HTTP-only / domain-less deployments

The cookie scheme defaults to CookieSecurePolicy.SameAsRequest, so plain http://localhost and on-prem deployments without TLS work out of the box. For production over HTTPS, harden by passing o.Cookie.SecurePolicy = CookieSecurePolicy.Always into AddTechTeaStudioCookieAuth(...). HSTS is only emitted by SecurityHeadersMiddleware when the inbound request is HTTPS — never on HTTP.

The library refuses to start with a missing or short signing key (< 32 UTF-8 bytes).

401 response contract

When the bearer pipeline rejects a request, the response body is:

{ "error": "token_expired", "message": "Token has expired.", "traceId": "0HMV..." }

error is a stable string from AuthErrorCodes:

  • missing_token, unauthorized — generic.
  • token_expired, token_not_yet_valid — lifetime errors.
  • invalid_signature, invalid_issuer, invalid_audience, malformed_token — structural errors.

Switch on error to drive client behaviour (e.g. token_expired → silently refresh).

Public API (current shipped surface)

namespace TechTeaStudio.Auth.Abstractions;

public interface ITokenProvider
{
    string CreateToken(string userId, IEnumerable<Claim> claims, TimeSpan lifetime);
    ClaimsPrincipal? ValidateToken(string token);
}

public interface ITokenReader
{
    AuthTokenInfo? TryRead(string token);
}

public interface IPasswordHasher
{
    string Hash(string password);
    bool Verify(string hashedPassword, string providedPassword);
}

public interface IRefreshTokenStore
{
    Task<RefreshToken?> GetByTokenHashAsync(string tokenHash, CancellationToken ct = default);
    Task<IReadOnlyList<RefreshToken>> GetActiveForUserAsync(string userId, CancellationToken ct = default);
    Task CreateAsync(RefreshToken token, CancellationToken ct = default);
    Task RevokeAsync(Guid id, string? replacedByTokenHash = null, CancellationToken ct = default);
    Task RevokeAllForUserAsync(string userId, CancellationToken ct = default);
    Task<int> CleanupExpiredAsync(DateTimeOffset cutoff, CancellationToken ct = default);
    Task DeleteAllForUserAsync(string userId, CancellationToken ct = default);
}

Observability

AuthDiagnostics exposes a System.Diagnostics.Metrics.Meter named TechTeaStudio.Auth and an ActivitySource of the same name. Counter names are Prometheus-friendly:

  • tts_auth_login_succeeded_total
  • tts_auth_login_failed_total
  • tts_auth_tokens_issued_total
  • tts_auth_refresh_rotated_total
  • tts_auth_refresh_reuse_total
  • tts_auth_accounts_locked_total

Plug an OpenTelemetry pipeline at the TechTeaStudio.Auth meter and they scrape into a single dashboard.

Replace NullAuthAuditLogger with your own IAuthAuditLogger to get strongly-typed events (LoginSucceeded, TokenIssued, RefreshReuseDetected, …) to a database or log sink.

Project layout

Auth/
├── src/TechTeaStudio.Auth/
│   ├── TechTeaStudio.Auth.sln
│   ├── TechTeaStudio.Auth/                          <- NuGet package source
│   │   ├── Abstractions/                            <- contracts + value objects
│   │   ├── Jwt/                                     <- JwtTokenProvider / JwtTokenReader
│   │   ├── Passwords/                               <- Pbkdf2PasswordHasher
│   │   ├── RefreshTokens/                           <- service, hasher, store, cleanup
│   │   ├── Lockout/                                 <- ILoginAttemptTracker + in-memory
│   │   ├── Revocation/                              <- deny-list + cleanup
│   │   ├── Tokens/                                  <- signed one-shot tokens
│   │   ├── TwoFactor/                               <- TOTP + recovery codes + enrollment
│   │   ├── Profiles/                                <- multi-app claim profiles
│   │   ├── Observability/                           <- IAuthAuditLogger + AuthDiagnostics
│   │   ├── AspNetCore/                              <- AddTechTeaStudioAuth(), middleware, ApiKey scheme
│   │   └── AuthOptions.cs
│   └── TechTeaStudio.Auth.Tests/                    <- xUnit + FluentAssertions
├── docs/                                            <- recipes + migration guides
├── .github/workflows/dotnet.yml                     <- CI (shared TTS NuGet publish workflow)
├── CHANGELOG.md
├── LICENSE
├── SECURITY.md
└── README.md

Build & test

dotnet build src/TechTeaStudio.Auth/TechTeaStudio.Auth.sln
dotnet test  src/TechTeaStudio.Auth/TechTeaStudio.Auth.sln

The library multi-targets net6.0;net8.0;net9.0;net10.0. net7.0 is intentionally skipped (EOL). The test project targets net8.0;net9.0;net10.0 only.

Versioning & release

Version lives in TechTeaStudio.Auth.csproj as a 3-part <Version>X.Y.Z</Version>. Bump rules:

  • Bug fix → Z + 1
  • New feature, source-compatible → Y + 1, reset Z = 0
  • Breaking change in public API → X + 1 (after 1.0), reset Y = Z = 0

Commit format is vX.Y.Z <short description>. Push to product triggers the shared TechTeaStudio NuGet publish workflow, which packs and pushes to nuget.org with --skip-duplicate. Never push to nuget.org manually.

See CHANGELOG.md for the full release history.

Further reading

License

Licensed under the MIT License. Copyright © Tech Tea Studio.

<p align="center"> Built as part of the Hyperion Ecosystem by <a href="https://techteastudio.cc">TechTeaStudio</a>. </p>

Product Compatible and additional computed target framework versions.
.NET net6.0 is compatible.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on TechTeaStudio.Auth:

Package Downloads
TechTeaStudio.Auth.OAuth.Abstractions

Provider-agnostic OAuth / OIDC sign-in orchestration for TechTeaStudio.Auth. Defines IExternalAuthProvider, IExternalLoginStore, and the three-outcome ExternalLoginService (authenticated / requires-password / requires-registration). Provider-specific validators live in sibling packages (TechTeaStudio.Auth.OAuth.Google, .Apple, .GitHub, ...).

TechTeaStudio.Auth.EFCore

EF Core implementation of TechTeaStudio.Auth's IRefreshTokenStore. Ship-ready entity, fluent mapping helper, and the store class.

TechTeaStudio.Auth.Redis

Redis-backed IRefreshTokenStore and ILoginAttemptTracker for TechTeaStudio.Auth — distributed lockout and refresh storage on top of StackExchange.Redis.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.2 245 5/19/2026
0.8.0 375 5/18/2026
0.7.0 283 5/13/2026
0.6.1 246 5/13/2026
0.6.0 220 5/13/2026
0.5.0 146 5/13/2026
0.3.1 94 5/13/2026

v0.5.0 — Breaking-change cleanup release.

Configuration:
 * AuthOptions nested into Jwt / RefreshTokens / Lockout sub-options. Migrate appsettings keys: Auth:SecretKey -> Auth:Jwt:SecretKey, Auth:TokenLifetime -> Auth:Jwt:TokenLifetime, Auth:RefreshTokenLifetime -> Auth:RefreshTokens:Lifetime, Auth:MaxFailedLoginAttempts -> Auth:Lockout:MaxFailedAttempts, etc.

Wire-up:
 * AddTechTeaStudioAuth(...) now returns IAuthBuilder. All defaults use TryAdd; swap via .UseRefreshTokenStore<...>() / .UseLoginAttemptTracker<...>() / .UseAuthAuditLogger<...>() / .UseClaimsProfile<...>() / .UseRefreshClaimsResolver<...>().

Removed:
 * HyperionClaimsProfile, PelloClaimsProfile, ClaimsProfiles (product-specific — moved out of the library).
 * AuthClaims.LegacyNameId constant + JwtTokenReader fallback to nameid (Hyperion-specific — reimplement locally in your app if needed).
 * AuthRateLimit extensions — use Microsoft.AspNetCore.RateLimiting directly. See RECIPES.
 * X-XSS-Protection header (deprecated, can introduce XSS in legacy browsers).

Fixed:
 * SignedTokenService now resolves its HMAC key via SigningKeyResolver — works after migrating to Signing.Keys (RS256/ES256). Previously it was hardcoded to Jwt.SecretKey and broke silently.
 * Swashbuckle filter now picks up Minimal API endpoints declared with .RequireAuthorization() (was missing the lock icon for them).
 * Swashbuckle package no longer references the base TechTeaStudio.Auth package.

Added:
 * IRefreshClaimsResolver — RotateAsync(token, ct) overload that rebuilds claims via this resolver, removes the need for callers to thread claims into refresh endpoints.
 * SHOUTY XML-doc warnings on every in-memory store / tracker.
 * JWKS endpoint sends Cache-Control: max-age=600.
 * Cookie scheme defaults to SecurePolicy.SameAsRequest (works on plain HTTP — opt into Always in production).

Changed:
 * TechTeaStudio.Auth.EFCore: RefreshTokenEntity.RowVersion -> ConcurrencyStamp (string). Works on every provider; SQL Server / Postgres / SQLite friendly.

Full changelog: https://github.com/TechTeaStudio/Auth/blob/main/CHANGELOG.md