Zautha.AspNetCore
0.4.0
dotnet add package Zautha.AspNetCore --version 0.4.0
NuGet\Install-Package Zautha.AspNetCore -Version 0.4.0
<PackageReference Include="Zautha.AspNetCore" Version="0.4.0" />
<PackageVersion Include="Zautha.AspNetCore" Version="0.4.0" />
<PackageReference Include="Zautha.AspNetCore" />
paket add Zautha.AspNetCore --version 0.4.0
#r "nuget: Zautha.AspNetCore, 0.4.0"
#:package Zautha.AspNetCore@0.4.0
#addin nuget:?package=Zautha.AspNetCore&version=0.4.0
#tool nuget:?package=Zautha.AspNetCore&version=0.4.0
Zautha.AspNetCore
Zautha for ASP.NET Core (.NET 10): authenticate your API's requests by the Zautha session token
your frontend sends (@zautha/js, @zautha/react), and manage your users and sessions through the Backend API.
Install
dotnet add package Zautha.AspNetCore
Keep the keys out of source control, e.g. with user secrets during development:
dotnet user-secrets init
dotnet user-secrets set "Zautha:PublishableKey" "pk_test_…"
dotnet user-secrets set "Zautha:SecretKey" "sk_test_…"
Authenticate requests
using System.Security.Claims;
using Zautha.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddZauthaAuthentication(options =>
{
options.PublishableKey = builder.Configuration["Zautha:PublishableKey"];
options.AuthorizedParties = ["https://app.example.com"];
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/api/whoami", (ClaimsPrincipal user) => user.GetZauthaAuth().UserId).RequireAuthorization();
app.Run();
The frontend sends the token as Authorization: Bearer <token> (await zautha.session.getToken()). A request is
authenticated only when the token
- is signed (RS256) by one of your instance's keys, read from its JWKS and cached for an hour (an unknown key id triggers a refetch, at most every five minutes),
- was issued by your instance's Frontend API (from the publishable key) and has not expired (5 s tolerance),
- was minted for one of
AuthorizedParties, the exact origins your frontends are served from.
Anything else gets 401 with WWW-Authenticate: Bearer; the reason goes to the log, never to the response. The
options are checked when the app starts: a malformed publishable key or an empty AuthorizedParties stops it.
user.GetZauthaAuth() returns the user id (user_…), session id (sess_…), the authorized party and the minutes since
the last first- and second-factor verification. It throws when the request was not authenticated by Zautha — call it
only in endpoints that require authorization. Custom claims from your instance's session token template (when your
instance defines them) are plain claims: user.FindFirst("plan")?.Value.
To verify without any network request, set options.JwtKey to one of your instance's public signing keys as an RSA
public key in PEM (-----BEGIN PUBLIC KEY-----), e.g. made from a key of the instance's JWKS (GetJwksAsync() below).
The scheme (ZauthaDefaults.AuthenticationScheme, "Zautha") is a JwtBearer scheme; its events are yours, e.g. a JSON
body for 401:
using Microsoft.AspNetCore.Authentication.JwtBearer;
builder.Services.Configure<JwtBearerOptions>(ZauthaDefaults.AuthenticationScheme, options =>
options.Events = new JwtBearerEvents
{
OnChallenge = async context =>
{
context.HandleResponse();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
context.Response.Headers.WWWAuthenticate = "Bearer";
await context.Response.WriteAsJsonAsync(new { code = "unauthenticated" });
},
});
Reverification
For a sensitive operation (changing a payout account, deleting data), ask for a recent identity check instead of just a signed-in user:
app.MapPost("/api/transfer", () => Results.Ok(new { ok = true }))
.RequireAuthorization()
.RequireZauthaReverification(ZauthaReverification.Strict);
RequireZauthaReverification lets the request through only when the user verified their identity recently enough:
| Requirement | Window | User with two-step verification | User without it |
|---|---|---|---|
ZauthaReverification.Strict |
10 minutes | second factor | first factor |
ZauthaReverification.Moderate |
1 hour | second factor | first factor |
ZauthaReverification.Lax |
1 day | second factor | first factor |
ZauthaReverification.StrictMfa |
10 minutes | both factors | first factor |
The first factor is the password, an email code or a link; the second factor is an authenticator app code or a backup
code. ZauthaReverification.Within(TimeSpan.FromMinutes(30), ZauthaReverificationLevel.FirstFactor) sets both yourself
(whole minutes from 1 to 1440; the level is SecondFactor when left out, and FirstFactor always asks for the first
factor). Otherwise it answers 403 with an application/problem+json body whose code is reverification_required
and whose reverification member says what was asked for ({"level":"second_factor","after_minutes":10}). On the
frontend, useReverification (@zautha/react) recognizes that response, asks the user to verify again (with the factors
that level needs) and retries the request with a fresh token. The filter works on route groups too; a request that is
not authenticated by Zautha is challenged (401), so keep RequireAuthorization().
In your own code, user.GetZauthaAuth().IsReverified(ZauthaReverification.Strict) answers the same question. The session
token counts whole minutes since each verification (FactorVerificationAge; the second is -1 for a user without
two-step verification), so a 10-minute window passes a token that says 9 and not one that says 10. A token can be up to
about a minute old, so the decision can lag Zautha's own by about a minute.
Backend API
builder.Services.AddZauthaClient(options =>
{
options.SecretKey = builder.Configuration["Zautha:SecretKey"];
});
app.MapGet("/api/me", async (ClaimsPrincipal user, IZauthaClient zautha, CancellationToken ct) =>
{
var me = await zautha.Users.GetAsync(user.GetZauthaAuth().UserId, ct);
// Only what the browser may see: private metadata stays on the server.
return Results.Ok(new { id = me.Id, primary_email_address = me.PrimaryEmailAddress, first_name = me.FirstName, last_name = me.LastName });
}).RequireAuthorization();
IZauthaClient covers users (Users.ListAsync, GetAsync, CreateAsync, UpdateAsync, DeleteAsync, BanAsync,
UnbanAsync, RemoveMfaAsync, ListSessionsAsync), sessions (Sessions.RevokeAsync) and the instance's public keys (GetJwksAsync).
CreateAsync(request, idempotencyKey): retrying with the same key within 24 hours returns the first response instead of creating a second user.UpdateAsyncsends only the properties you assign:new UpdateUserRequest { FirstName = null }clears the first name, other fields stay as they are, and assigned metadata replaces the whole object.RemoveMfaAsyncremoves a user's two-step verification (authenticator app, passkeys and backup codes) when they lost their devices, and ends all their sessions;ZauthaUser.MfaEnabled,TotpEnabled,PasskeysandBackupCodesRemainingshow where a user stands.- Every error response throws
ZauthaApiException; branch onCode(resource_not_found,form_identifier_exists,too_many_requestswithRetryAfter, …) and quoteTraceIdto support. The client does not retry by itself, but app-wide resilience handlers (e.g.ConfigureHttpClientDefaults(b => b.AddStandardResilienceHandler())in Aspire's ServiceDefaults) apply to it too: pass anidempotencyKeytoCreateAsyncwhen you use them. - Requests time out after 10 seconds;
AddZauthaClientreturns theIHttpClientBuilderfor your own handlers and timeout.
License
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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 (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.