TechTeaStudio.Auth
0.8.2
dotnet add package TechTeaStudio.Auth --version 0.8.2
NuGet\Install-Package TechTeaStudio.Auth -Version 0.8.2
<PackageReference Include="TechTeaStudio.Auth" Version="0.8.2" />
<PackageVersion Include="TechTeaStudio.Auth" Version="0.8.2" />
<PackageReference Include="TechTeaStudio.Auth" />
paket add TechTeaStudio.Auth --version 0.8.2
#r "nuget: TechTeaStudio.Auth, 0.8.2"
#:package TechTeaStudio.Auth@0.8.2
#addin nuget:?package=TechTeaStudio.Auth&version=0.8.2
#tool nuget:?package=TechTeaStudio.Auth&version=0.8.2
<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 for .NET. Hash passwords, hand out JWT access tokens, rotate refresh tokens, track device sessions, lock out brute-force attackers, and wire it all into ASP.NET Core in one line. No full identity framework required. </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>
What this gives you
If you are building a .NET web API that needs to log users in and keep them logged in, you need to solve five problems:
- Hash passwords safely so a database leak does not leak passwords.
- Hand out short-lived access tokens the client puts on every request.
- Hand out long-lived refresh tokens so the client can stay logged in without typing the password again.
- Lock out attackers who try thousands of password guesses.
- Wire all of that into ASP.NET Core without a 200-line
Program.cs.
TechTeaStudio.Auth solves all five with one NuGet package and one line of DI registration. You keep full control of your own user table; the library never tells you what a "user" looks like.
When to use it (and when not to)
Use it when you own a web API or BFF, users log in against your own database, and you want production-ready primitives without pulling in ASP.NET Core Identity or running an OAuth2 server.
Skip it when you need a full OAuth2 / OIDC server (use OpenIddict or Duende IdentityServer), when authentication is delegated entirely to Auth0 / Cognito / Entra (use the matching Microsoft.AspNetCore.Authentication.* package directly), or when you want ASP.NET Core Identity's built-in UI scaffolding for users and roles.
Plain-English glossary
If you are new to auth in .NET, here are the terms used throughout this README:
| Term | What it means |
|---|---|
| Access token | Short-lived JWT (default 30 min). Client sends it in Authorization: Bearer ... on every request. |
| Refresh token | Long-lived random string (default 7 days). Client uses it to ask for a fresh access token without re-entering the password. |
| Rotation | Every time the client trades a refresh token for a new access token, the old refresh token is killed and a new one is issued. Stops stolen tokens from working forever. |
| Replay | An attacker presenting an already-used refresh token. The library detects this and kills the whole session. |
| Lockout | After N failed password attempts, the account is locked for some duration. Stops brute-force guessing. |
| Claims profile | A small class that decides which user properties (email, roles, custom fields) go into the JWT. Each app owns its own profile. |
| PBKDF2 | The password-hashing algorithm. Slow on purpose so an attacker with the database cannot crack passwords cheaply. |
Install
dotnet add package TechTeaStudio.Auth
Or pin a specific version in .csproj:
<PackageReference Include="TechTeaStudio.Auth" Version="0.8.0" />
Optional companion packages:
TechTeaStudio.Auth.EFCore— store refresh tokens in your existing EF CoreDbContext.TechTeaStudio.Auth.Redis— store refresh tokens, lockout counters, and JWT deny-list in Redis (multi-instance prod).TechTeaStudio.Auth.OAuth.Google/TechTeaStudio.Auth.OAuth.GitHub— sign in with Google or GitHub.TechTeaStudio.Auth.OAuth.EFCore— store external login links in EF Core.TechTeaStudio.Auth.Swashbuckle— make the Swagger "Authorize" button work with the library.
Full lifecycle: from blank project to logged-in user
This is one continuous walkthrough. Each step builds on the last. At the end you will have a small API where a user can sign up, log in, call a protected endpoint, refresh their session, see their devices, and log out, with refresh tokens stored in a real database.
Step 1: Configure
Add an Auth section to appsettings.json. The SecretKey must be at least 32 UTF-8 bytes; in production keep it in user-secrets or a key vault, never in source.
{
"Auth": {
"Jwt": {
"SecretKey": "replace-with-a-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" }
}
}
Step 2: Wire it into Program.cs
One call sets up everything: JWT validation, password hashing, the refresh-token service, lockout tracking, the deny-list, the background cleanup workers, and appsettings validation at startup.
using TechTeaStudio.Auth.AspNetCore;
using TechTeaStudio.Auth.EFCore;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDb>(o => o.UseNpgsql(builder.Configuration.GetConnectionString("Db")));
builder.Services.AddTechTeaStudioAuth(builder.Configuration)
.UseRefreshTokenStore<EfCoreRefreshTokenStore<AppDb>>(); // persist refresh tokens in your DB
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
Step 3: Plug the refresh-token table into your DbContext
public sealed class AppDb : DbContext
{
public AppDb(DbContextOptions<AppDb> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<RefreshTokenEntity> RefreshTokens => Set<RefreshTokenEntity>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<User>().HasIndex(u => u.Email).IsUnique();
b.AddTechTeaStudioRefreshTokens(); // adds the TtsRefreshTokens table + indexes
}
}
public sealed class User
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Email { get; set; } = "";
public string Username { get; set; } = "";
public string PasswordHash { get; set; } = "";
}
Already on an older release? The 0.8.0 upgrade adds two nullable columns. Run the pre-baked SQL once before the first deploy:
await db.Database.ExecuteSqlRawAsync(SchemaMigrations.AddDeviceColumnsSqlPostgres());(
AddDeviceColumnsSqlSqlServer()andAddDeviceColumnsSqlSqlite()are also available.) Fresh deployments get the columns automatically.
Step 4: Sign-up endpoint (hash the password, store the user)
[ApiController]
[Route("auth")]
public sealed class AuthController(AppDb db, IPasswordHasher passwords, RefreshTokenService refresh) : ControllerBase
{
public sealed record SignupRequest(string Email, string Username, string Password);
[HttpPost("signup")]
public async Task<IActionResult> Signup([FromBody] SignupRequest req, CancellationToken ct)
{
if (await db.Users.AnyAsync(u => u.Email == req.Email, ct))
return Conflict(new { error = "email_taken" });
var user = new User
{
Email = req.Email,
Username = req.Username,
PasswordHash = passwords.Hash(req.Password),
};
db.Users.Add(user);
await db.SaveChangesAsync(ct);
return Ok(new { user.Id });
}
}
passwords.Hash(...) is PBKDF2-SHA256 with 600 000 iterations and a 16-byte salt. The output already contains the salt, so you store one string and you are done.
Step 5: Login endpoint (verify password, issue tokens)
public sealed record LoginRequest(string Email, string Password, string? DeviceId, string? DeviceInfo);
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginRequest req, CancellationToken ct)
{
var user = await db.Users.SingleOrDefaultAsync(u => u.Email == req.Email, ct);
if (user is null || !passwords.Verify(user.PasswordHash, req.Password))
return Unauthorized(new { error = "bad_credentials" });
var claims = new[]
{
new Claim(AuthClaims.Username, user.Username),
new Claim(AuthClaims.Email, user.Email),
};
// deviceId / deviceInfo are optional. When supplied, they show up on /sessions
// so the user can tell which device a session belongs to. Preserved across rotations.
var pair = await refresh.IssueAsync(
user.Id.ToString(),
claims,
deviceId: req.DeviceId,
deviceInfo: req.DeviceInfo,
cancellationToken: ct);
return Ok(new
{
pair.AccessToken,
pair.RefreshToken,
pair.RefreshTokenExpiresAt,
});
}
The response gives the client an access token (put in Authorization: Bearer ... on every request) and a refresh token (store securely, send only to /auth/refresh).
Step 6: Protect an endpoint
[ApiController]
[Route("me")]
[Authorize] // requires a valid Bearer token
public sealed class MeController : ControllerBase
{
[HttpGet]
public IActionResult Get()
=> Ok(new
{
UserId = User.FindFirstValue(AuthClaims.Subject),
Username = User.FindFirstValue(AuthClaims.Username),
Email = User.FindFirstValue(AuthClaims.Email),
});
}
If the token is missing, expired, or tampered with, the library replies 401 with a structured JSON body (see 401 response contract below). The frontend can branch on error == "token_expired" and silently call /auth/refresh.
Step 7: Refresh endpoint (trade old refresh token for new pair)
public sealed record RefreshRequest(string RefreshToken);
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req, CancellationToken ct)
{
// No-claims overload: an IRefreshClaimsResolver pulls the latest claims for you.
var pair = await refresh.RotateAsync(req.RefreshToken, ct);
if (pair is null) return Unauthorized(new { error = "invalid_refresh_token" });
return Ok(new { pair.AccessToken, pair.RefreshToken, pair.RefreshTokenExpiresAt });
}
Each successful refresh kills the presented token and emits a new one. If the same refresh token is presented twice (the second presentation must be a stolen copy), the library kills the entire rotation chain. The client is forced back to the login screen, which is exactly what you want.
To register a claims resolver so RotateAsync(token, ct) knows what claims to put in the new JWT:
public sealed class MyClaimsResolver(AppDb db) : IRefreshClaimsResolver
{
public async Task<IEnumerable<Claim>> ResolveClaimsAsync(string userId, CancellationToken ct)
{
var u = await db.Users.SingleAsync(u => u.Id == Guid.Parse(userId), ct);
return new[]
{
new Claim(AuthClaims.Username, u.Username),
new Claim(AuthClaims.Email, u.Email),
};
}
}
// In Program.cs:
builder.Services.AddTechTeaStudioAuth(builder.Configuration)
.UseRefreshTokenStore<EfCoreRefreshTokenStore<AppDb>>()
.UseRefreshClaimsResolver<MyClaimsResolver>();
Step 8: List the user's sessions (device attribution)
Because IssueAsync accepted deviceId / deviceInfo, you can show "I am logged in on iPhone 13, Chrome on PC, Firefox on Linux" in a Settings → Sessions screen:
[HttpGet("sessions")]
[Authorize]
public async Task<IActionResult> Sessions([FromServices] IRefreshTokenStore store, CancellationToken ct)
{
var userId = User.FindFirstValue(AuthClaims.Subject)!;
var tokens = await store.GetActiveForUserAsync(userId, ct);
return Ok(tokens.Select(t => new
{
t.Id,
t.DeviceId,
t.DeviceInfo,
t.CreatedAt,
t.ExpiresAt,
}));
}
Step 9: Logout (revoke this session)
[HttpPost("logout")]
public async Task<IActionResult> Logout([FromBody] RefreshRequest req, CancellationToken ct)
{
await refresh.RevokeAsync(req.RefreshToken, ct);
return NoContent();
}
To kill every active session for a user (account compromised, password changed):
await store.RevokeAllForUserAsync(userId, ct);
Step 10: Try it from the command line
BASE=http://localhost:5180
# 1. Sign up
curl -sX POST $BASE/auth/signup -H 'content-type: application/json' \
-d '{"email":"u@x","username":"alice","password":"correct horse battery staple"}'
# 2. Login (capture the tokens)
TOKENS=$(curl -sX POST $BASE/auth/login -H 'content-type: application/json' \
-d '{"email":"u@x","password":"correct horse battery staple","deviceInfo":"PC","deviceId":"laptop-abc"}')
ACCESS=$(echo $TOKENS | jq -r .accessToken)
REFRESH=$(echo $TOKENS | jq -r .refreshToken)
# 3. Call /me with the Bearer token
curl -s $BASE/me -H "Authorization: Bearer $ACCESS"
# 4. Refresh
TOKENS2=$(curl -sX POST $BASE/auth/refresh -H 'content-type: application/json' \
-d "{\"refreshToken\":\"$REFRESH\"}")
# 5. Logout
curl -sX POST $BASE/auth/logout -H 'content-type: application/json' \
-d "{\"refreshToken\":\"$(echo $TOKENS2 | jq -r .refreshToken)\"}"
That is the full lifecycle. Everything else in this README is optional polish.
What the one-line wire-up actually registers
AddTechTeaStudioAuth(configuration) registers:
ITokenProvider,ITokenReader(issue / read JWTs)IPasswordHasher(PBKDF2-SHA256)IRefreshTokenStore(in-memory default; swap via the builder)RefreshTokenService(issue, rotate, revoke)IRefreshClaimsResolver(null default; supply your own)ILoginAttemptTracker(in-memory default; lockout)IRevokedTokenStore(in-memory default; JWT deny-list)IAuthAuditLogger(null default; observability sink)- JWT bearer authentication scheme + ASP.NET Core authorization
RefreshTokenCleanupService+RevokedTokenCleanupServicebackground workers- Startup validation of
AuthOptions
All store / tracker / logger registrations use TryAdd*. Swap defaults via the fluent builder returned by AddTechTeaStudioAuth(...):
builder.Services.AddTechTeaStudioAuth(builder.Configuration)
.UseRefreshTokenStore<EfCoreRefreshTokenStore<MyDbContext>>()
.UseLoginAttemptTracker<RedisLoginAttemptTracker>()
.UseAuthAuditLogger<MyDbAuthAuditLogger>()
.UseClaimsProfile<MyAppClaimsProfile>()
.UseRefreshClaimsResolver<MyClaimsResolver>();
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 plus a hand-written hasher and a full identity framework. If you are 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.
Extras you will probably want
One-shot signed tokens (password reset, email confirmation)
// 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 (machine-to-machine)
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.
Sign in with Google / GitHub
Install TechTeaStudio.Auth.OAuth.Google (or .GitHub) and wire one extra call. See docs/OAUTH.md for the full three-outcome flow (authenticate, link-existing, complete-registration).
Multi-app claim profiles
Different apps want different claim shapes. The library defines IClaimsProfile; each app implements its own and registers it via DI:
public sealed class MyAppClaimsProfile : IClaimsProfile
{
public string Name => "MyApp";
public IEnumerable<Claim> BuildClaims(ClaimsBuilderInput input)
{
// DO NOT yield AuthClaims.Subject ("sub") here — see warning below.
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.
⚠️ Do NOT emit sub from your IClaimsProfile
JwtTokenProvider.CreateToken already prepends sub = userId to the claim list on every issuance. If your IClaimsProfile.BuildClaims also yields new Claim(AuthClaims.Subject, input.UserId), the resulting ClaimsIdentity has two claims with the same type "sub". JwtSecurityTokenHandler then serializes them as a JSON array:
{ "sub": ["the-user-id", "the-user-id"], ... }
That violates RFC 7519 ("the sub value is a case-sensitive string"). Microsoft.IdentityModel validators (both the bearer middleware and the newer JsonWebTokenHandler) reject the JWT, surfacing as:
IDX12723: Unable to decode the payload as Base64Url encoded stringin some readers (misleading — the payload base64url-decodes fine; the rejection is on the array-typedsubafter JSON parse).401 Unauthorizedon every[Authorize]endpoint.- Refresh chain loops on mobile clients (refresh succeeds because it doesn't validate the JWT, but
/me//sessions/ anything authenticated 401s, and the client retries forever).
If your app also needs a legacy nameid claim for older downstream services, emit nameid (not sub) — the lib doesn't touch nameid:
if (!string.IsNullOrEmpty(input.UserId))
yield return new Claim("nameid", input.UserId); // OK: distinct claim type
This was a real production bug — please don't repeat it.
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: a 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
Heads up:
TechTeaStudio.Auth.Redisis in early-stage (v0.5). It passes the contract test kit, butRevokeAsync(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 on the client to drive behaviour (e.g. token_expired → silently call /auth/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);
}
RefreshTokenService (the orchestrator on top of the store):
// Issue: device fields are optional and persisted with the row so /sessions can surface them.
Task<TokenPair> IssueAsync(string userId, IEnumerable<Claim> claims, CancellationToken ct = default);
Task<TokenPair> IssueAsync(string userId, IEnumerable<Claim> claims, string? deviceId, string? deviceInfo, CancellationToken ct = default);
// Rotate: kills the presented token, emits a new pair. Returns null on unknown / expired / replayed.
Task<TokenPair?> RotateAsync(string presentedRefreshToken, CancellationToken ct = default);
Task<TokenPair?> RotateAsync(string presentedRefreshToken, IEnumerable<Claim> claims, CancellationToken ct = default);
// Revoke: kill one session.
Task RevokeAsync(string presentedRefreshToken, 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_totaltts_auth_login_failed_totaltts_auth_tokens_issued_totaltts_auth_refresh_rotated_totaltts_auth_refresh_reuse_totaltts_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, ...) into 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, resetZ = 0 - Breaking change in public API →
X + 1(after1.0), resetY = 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
- docs/RECIPES.md: common patterns: login, refresh, revoke, reset, 2FA, API keys, audit.
- docs/OAUTH.md: Google / GitHub / Apple sign-in via the OAuth sibling packages.
- docs/MIGRATION-Hyperion.md: moving Hyperion Omni Client onto the library.
- docs/MIGRATION-Pello.md: moving Pello onto the library.
- SECURITY.md: threat model and reporting policy.
- samples/MinimalApi/: runnable single-file version of the lifecycle walkthrough above.
- samples/BlazorServer/: cookie + bearer integrated into a Blazor Server app.
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 | Versions 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. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 9.0.0)
- System.IdentityModel.Tokens.Jwt (>= 8.2.0)
-
net6.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 6.0.36)
- System.IdentityModel.Tokens.Jwt (>= 7.7.1)
-
net8.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.10)
- System.IdentityModel.Tokens.Jwt (>= 8.2.0)
-
net9.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 9.0.0)
- System.IdentityModel.Tokens.Jwt (>= 8.2.0)
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.
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