A self-hostable authentication core for .NET — email/password, passwordless one-time codes (email + SMS), passkeys (WebAuthn/FIDO2), social login & SSO, account linking, MFA, email verification, and a per-user metadata store. An independent, clean-room design built from a public feature model, not a port or migration of any existing project's source.
Status: beta (v0.0.1-beta.11). Module logic, session security, the auth methods below, shared-schema multi-tenancy, and all three storage adapters work end-to-end and are covered by unit, Testcontainers and Playwright (WebAuthn) tests. Pre-1.0 — APIs may still shift, and provider endpoints track the upstream APIs.
| Package | Purpose |
|---|---|
Klassd.Auth.Abstractions |
Store interfaces + DB-agnostic record types |
Klassd.Auth.Core |
Auth logic: email/password, sessions, third-party, MFA, email verification, metadata, account linking |
Klassd.Auth.AspNetCore |
JSON/JWT HTTP delivery — one MapKlassdAuth() call wires the whole API |
Klassd.Auth.AspNetCore.Cookies |
Cookie sign-in for server-rendered / Blazor apps + external-SSO & linking seam |
Klassd.Auth.Passwordless |
Passwordless one-time codes over email/SMS — AddPasswordless() + endpoints |
Klassd.Auth.Passkeys |
Passkeys (WebAuthn/FIDO2) via Fido2NetLib — AddPasskeys() + ceremony endpoints |
Klassd.Auth.Sms.Twilio |
Twilio ISmsSender for passwordless-over-SMS — AddTwilioSms() |
Klassd.Auth.Webhooks |
Inbound HMAC-signed webhooks to disable/enable/delete/anonymize users — AddKlassdAuthWebhooks() |
Klassd.Auth.Dashboard |
Drop-in Blazor (Interactive Server) user-admin dashboard — AddKlassdAuthDashboard() |
Klassd.Auth.OpenIdConnect |
OIDC external login + Microsoft Entra ID (AddEntraId) + Google (AddGoogle) |
Klassd.Auth.OAuth |
OAuth 2.0 (non-OIDC) providers — GitHub, Facebook, Instagram, TikTok |
Klassd.Auth.Data.Sqlite |
SQLite adapter (raw Microsoft.Data.Sqlite, JSON-in-TEXT) |
Klassd.Auth.Data.Postgres |
PostgreSQL adapter (raw Npgsql, jsonb) |
Klassd.Auth.Data.MongoDb |
MongoDB adapter (MongoDB.Driver) |
Klassd.Auth.Migration |
Import users into Klassd.Auth from Auth0 & SuperTokens (JSON exports) — AddAuthMigration() |
Klassd.Auth.Migration.SuperTokens.Postgres |
Read a SuperTokens core DB directly over PostgreSQL (Npgsql) by connection string |
Klassd.Auth.Migration.SuperTokens.MySql |
Read a SuperTokens core DB directly over MySQL (MySqlConnector) by connection string |
Klassd.Auth.Sample |
Runnable example host |
Storage adapters use raw drivers, no EF/ORM, matching the Klassd house convention.
builder.Services
.AddKlassdAuth(new SessionConfig { SigningKey = "<32+ byte secret>" })
.UseSqlite("Data Source=klassd-auth.db"); // or .UsePostgres(...) / .UseMongoDb(...)
var app = builder.Build();
app.MapKlassdAuth(); // mounts the full HTTP API; schema is created automatically at startup
app.Run();That's the whole host. The endpoints are shipped by the library — you don't hand-write them.
| Method & path | Purpose |
|---|---|
POST /auth/signup |
Email/password sign-up → session tokens |
POST /auth/signin |
Email/password sign-in → session tokens |
POST /auth/refresh |
Rotate refresh token, issue new access token |
POST /auth/logout |
Revoke a session |
POST /auth/email/send-verification |
Send a verification link |
GET /auth/email/verify?token= |
Consume a verification token |
POST /auth/mfa/enroll |
Generate a TOTP secret + otpauth:// URI |
POST /auth/mfa/verify |
Verify a TOTP code |
GET /auth/users/{id}/metadata |
Read user metadata JSON |
PATCH /auth/users/{id}/metadata |
Shallow-merge user metadata (null removes a key) |
GET /auth/jwks.json |
Public signing keys (populated under RS256; empty for HS256) |
GET /auth/.well-known/openid-configuration |
OIDC discovery doc (issuer + jwks_uri) for JWT-bearer auto-discovery |
app.MapKlassdAuthAdmin(authorizationPolicy: "Admin") adds (protected) admin endpoints —
GET/POST /auth/admin/users, GET /auth/admin/users/{id}, POST .../disable,
POST .../reset-password, GET/PUT .../roles. Responses never include password hashes.
auth.AddPasswordless(); // codes default to 6 digits, 10-min TTL, 5 attempts
auth.AddTwilioSms(sid, token, fromNumber); // optional: deliver SMS codes for real (else console)
app.MapKlassdPasswordless(); // JSON: POST /auth/passwordless/{start,verify}start sends a code to an email or phone (channel is "Email"/"Sms") and always returns
202 — it never reveals whether the identifier exists. verify checks the code (fixed-time
compare, TTL + attempt lockout) and returns session tokens, resolving or auto-provisioning the user
by email/phone. A cookie variant (MapKlassdPasswordlessCookie) signs the user in instead.
auth.AddPasskeys(o => { o.ServerDomain = "example.com"; o.Origins = ["https://example.com"]; });
app.MapKlassdPasskeys(); // POST /passkeys/{register,login}/{options,verify}Built on Fido2NetLib. Registration requires an authenticated user; login supports
usernameless/discoverable credentials. The ceremony challenge is held in a stateless,
DataProtection-protected cookie by default (multi-node-safe, no shared cache), with an in-memory
option for single-node. register/verify issues session tokens (or a cookie via
MapKlassdPasskeysCookie). Credentials persist in a passkey_credentials table per adapter.
An account is one identity with N attachable LoginMethods — add or remove any kind:
// On the cookie endpoints (the signed-in user links to THEIR account):
// GET /auth/link/{scheme} → challenge a provider, attach it on callback
// POST /auth/link/password → a social-/passwordless-only user gains a password
// POST /auth/unlink → remove a method (the last one is guarded)
// GET /auth/me/methods → list the caller's own login methods
// From code:
await accounts.LinkExternalAsync(userId, "facebook", info); // never steals an identity owned elsewhere
await accounts.AddPasswordAsync(userId, password); // false if one already exists
await accounts.UnlinkAsync(userId, methodId); // false if it's the last methodLinking is explicit and tied to the signed-in session. Optionally, an unauthenticated social
sign-in can auto-merge into an existing account — but only on a provider-verified matching
email (AutoLinkByVerifiedEmail, off by default; unverified-email auto-link is a takeover vector).
Some providers never share an email — Instagram and TikTok don't expose one at all, and
Facebook may be denied. Those accounts are provisioned with PrimaryEmail == null (it's nullable
by design); collect and verify an address after sign-in:
POST /auth/me/email (authenticated) [FromForm] email → sends a verification link
GET /auth/me/email/confirm?token=… → sets it as the verified primary email
start rejects an address already owned by another user (409). The confirm link's token is the
proof of ownership, so on confirm the address becomes the user's verified PrimaryEmail (and a
usable passwordless identity). From code: accounts.SetPrimaryEmailAsync(userId, email, verified)
(guards against an email owned by someone else) and accounts.IsEmailAvailableAsync(...). Apps that
require an email can gate access on user.PrimaryEmail is null and route to the collection page.
POST /auth/password/forgot { "identifier": "a@b.com" } → 202 always (no account enumeration)
POST /auth/password/reset { "token": "…", "newPassword": "…" } → 204 / 400
forgot emails a single-use link ({PasswordResetUrlBase}?token=…, ~1h TTL) to the resolved account;
reset consumes the token, sets the password (adding an email/password method if the account had
none), and revokes the user's existing sessions. Configure PasswordResetUrlBase on
MapKlassdAuth(o => …). (Distinct from the admin reset and the AccountLifecycleService below.)
AccountLifecycleService performs the destructive ops with their cross-store cascade — used by the
dashboard and the webhooks:
await lifecycle.DisableAsync(userId); // + revokes live sessions
await lifecycle.EnableAsync(userId);
await lifecycle.DeleteAsync(userId); // hard delete: user + login methods + sessions + passkeys + metadata
await lifecycle.AnonymizeAsync(userId); // GDPR erasure: strip PII/credentials, KEEP the id rowOne Klassd.Auth instance can serve many tenants out of one database. Each User and session carries a
TenantId (default "public", so single-tenant deployments need zero changes), the storage
adapters scope every identity lookup (by email / username / phone / third-party provider) and the
admin user list to the current tenant, and the access token carries the tenant as a tnt claim. The
same email can therefore register independently in different tenants as separate accounts; lookup by
id stays global (a GUID is unique). Isolation is enforced in the store layer, so every login method —
password, passwordless, passkey, OAuth — is tenant-scoped by construction.
The current tenant lives in a scoped ITenantContext. It's set from the request at login (the
/auth/signup and /auth/signin bodies accept an optional tenant) and rehydrated from the tnt
claim on authenticated requests:
app.UseAuthentication();
app.UseKlassdTenant(); // copies the access token's `tnt` claim into ITenantContext for the request
app.UseAuthorization();Tenant resolution is carried in the token (no per-request subdomain/host parsing); to scope a
unit of work yourself, set ITenantContext.TenantId before calling the services. Importing several
source databases into one multi-tenant instance is built in — see the migration section below.
A drop-in Blazor (Interactive Server) UI to maintain users — list/search, create, enable/disable, set password, edit roles, manage linked login methods, and delete/anonymize (typed confirm).
auth.AddKlassdAuthDashboard(); // after AddKlassdAuth(...) + a storage adapter (registers Blazor Server)
…
// Mounts the UI under a configurable path (default "/auth/dashboard") and ALWAYS requires login —
// anonymous visitors are redirected to the cookie login path. Pass a policy to also gate by role.
app.MapKlassdAuthDashboard(authorizationPolicy: "Admin");
app.MapKlassdAuthDashboard("/admin/users", "Admin"); // …or mount it anywhereThe mount is a self-contained pipeline branch — it owns its routing, authentication, antiforgery
and static assets, so the host doesn't add those for the dashboard. The host must still set
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets> in its csproj (else _framework/blazor.web.js
404s). Browse the authenticated UI at the trailing-slash URL (…/auth/dashboard/). See
Klassd.Auth.Sample for a complete host.
It also ships an import page (…/auth/dashboard/import) when AddAuthMigration() is registered:
upload an Auth0 / SuperTokens export, pick a target tenant, dry-run then apply — all without the CLI.
The import runs as a background job with live progress (it survives navigating away, and can be
cancelled), so the admin isn't blocked. To also offer direct live-database imports (host /
database / username / password fields for a running SuperTokens DB), the host registers a connection
source — this keeps the DB driver dependency out of the dashboard package:
auth.AddKlassdAuthDashboard(o =>
o.AddConnectionSource("supertokens-pg", "SuperTokens (PostgreSQL)", (p, appId) =>
{
var cs = new NpgsqlConnectionStringBuilder
{ Host = p.Host, Database = p.Database, Username = p.Username, Password = p.Password };
if (int.TryParse(p.Port, out var port)) cs.Port = port;
return new SuperTokensPostgresMigrationSource(cs.ConnectionString, new SuperTokensDbOptions { AppId = appId });
}));Lets an external system (e.g. a CS tool) act on accounts via an HMAC-signed request:
auth.AddKlassdAuthWebhooks(o => o.SigningSecrets.Add(builder.Configuration["Auth:Webhooks:Secret"]!));
app.MapKlassdAuthWebhooks(); // POST /auth/webhooks/usersPOST /auth/webhooks/users
X-Klassd-Timestamp: <unix seconds>
X-Klassd-Signature: sha256=<hex HMAC-SHA256 of "{timestamp}.{body}">
{ "action": "disable"|"enable"|"delete"|"anonymize", "userId"|"email": "…", "reason": "…" }
Verification mirrors the Klassd CMS outbound signing scheme and adds a timestamp tolerance (default
300s) to reject replays. Forged/stale/unsigned requests get 401; unknown users 404. Each applied
action is logged via ILogger for an audit trail.
Import an existing user base into Klassd.Auth — passwords, social logins, roles, metadata and TOTP
included. Point a source at the export file and run it through the MigrationRunner:
builder.Services
.AddKlassdAuth(sessionConfig)
.UseSqlite("Data Source=auth.db")
.AddAuthMigration(); // registers MigrationRunner + legacy-aware password verification
// ...later, from a scope (one-off CLI / admin endpoint / hosted task):
var runner = sp.GetRequiredService<MigrationRunner>();
// Auth0 — bulk-export NDJSON or the user-import JSON array. Map provider ids to yours.
var auth0 = new Auth0MigrationSource("auth0-users.json",
mapProvider: p => p == "google-oauth2" ? "google" : p);
// SuperTokens — the bulk-import document ({ "users": [...] }) or a bare array.
var superTokens = new SuperTokensMigrationSource("supertokens-users.json");
// SuperTokens — or read the core database directly (no export needed):
var superTokensDb = new SuperTokensPostgresMigrationSource( // .SuperTokens.Postgres package
"Host=localhost;Database=supertokens;Username=…;Password=…");
// var superTokensDb = new SuperTokensMySqlMigrationSource("Server=…;Database=supertokens;…"); // .SuperTokens.MySql
// Linked accounts (grouped by SuperTokens' primary_or_recipe_user_id) become one Klassd user
// with multiple login methods. Pass SuperTokensDbOptions to set AppId / schema / provider mapping.
var report = await runner.RunAsync(auth0, new MigrationOptions
{
DryRun = true, // verify first, write nothing
OnConflict = ConflictPolicy.Skip, // or .Merge to attach missing login methods
});
Console.WriteLine($"{report.Created} created, {report.Skipped} skipped, " +
$"{report.PasswordsDropped} need a reset, {report.Failed} failed");How it maps:
- Passwords — bcrypt and argon2 hashes carry over verbatim and verify at sign-in via the
legacy-aware hasher
AddAuthMigration()installs; a credential silently upgrades to Klassd's native pbkdf2 the next time it's set. Unverifiable hashes (Firebase scrypt, passlib pbkdf2, …) create a password-less account that the user resets — these are counted inreport.PasswordsDropped. - Social / OIDC — Auth0
identities[]and SuperTokensthirdpartylogin methods become linked third-party methods (the Auth0auth0database connection is treated as email/password, not social). - Passwordless, roles, metadata, TOTP — email/phone passwordless methods, roles, free-form
metadata and the TOTP secret all carry over (toggle each via
MigrationOptions).
Runs are idempotent: re-running matches existing users by email then username, and either skips or merges them. Sources stream the export, so large dumps don't load whole.
Set MigrationOptions.TenantId to import a source into a specific tenant (lookups and inserts are then
scoped to it, so the same email in two source systems becomes two separate accounts). To import many
sources at once, RunManyAsync runs each into its own tenant and returns a report per tenant:
var reports = await runner.RunManyAsync(
[
("acme", new SuperTokensPostgresMigrationSource("Host=…;Database=acme_st;…")),
("globex", new SuperTokensMySqlMigrationSource("Server=…;Database=globex_st;…")),
],
new MigrationOptions { OnConflict = ConflictPolicy.Skip }); // DryRun/flags shared; TenantId set per entryThe migrate-auth CLI exposes this too: --tenant <id> targets one tenant, and --manifest <file>
imports a JSON array of { tenant, source, conn|file, appId } entries — many databases into one
multi-tenant Klassd.Auth in a single run.
Run the migration as a one-shot operation, not on every pod's startup. The sample host exposes a
migrate-auth verb for exactly this (it creates the schema, runs the import, prints a report, exits —
without starting the web server):
# dry run first (no --apply), then commit
dotnet run -- migrate-auth --source supertokens-pg --conn "Host=…;Database=supertokens;…"
dotnet run -- migrate-auth --source supertokens-pg --conn "Host=…;Database=supertokens;…" --applyPackage it as a Kubernetes Job (manifest in docs/migrate-job.yaml). This
is what makes it safe:
- It won't re-run when your app restarts. Nothing in
AddAuthMigration()runs on startup — the migration only happens when you invoke it. AJobruns once; your webDeploymentnever touches it. - Racing replicas can't double-import, because a
Jobruns a single pod. (Note: the check-then-insert in the runner is not internally locked, so do not run the importer from N web replicas concurrently — the idempotent skip only protects sequential re-runs, not a true concurrent race. One Job, or rely on a unique-email index in your adapter as a backstop.)
The idempotent skip is then just a safety net: re-applying the Job is harmless.
If you can't run a separate Job and must migrate from app startup with several replicas, use the
guarded path. It's backed by IMigrationStateStore (provided by the Sqlite/Postgres/MongoDb
adapters): a durable completion ledger so it runs at most once ever, plus a distributed lease
so only one replica runs it — the lease auto-expires and is heartbeated, so a crashed holder can't
wedge it.
builder.Services
.AddKlassdAuth(sessionConfig)
.UsePostgres(cs)
.AddAuthMigration()
.RunMigrationOnStartup(
migrationId: "auth0-import-2026-06", // recorded in the ledger; runs once ever
sourceFactory: sp => new Auth0MigrationSource("/seed/auth0-export.json"),
configureOptions: o => o.OnConflict = ConflictPolicy.Skip);Every replica starts the hosted service; exactly one wins the lease and imports, the rest observe
AlreadyCompleted/LockHeldByAnother and move on. It runs in the background (never blocks the host)
and only writes the ledger on success, so a failed run retries on the next start. To drive it yourself
instead, resolve MigrationCoordinator and call RunOnceAsync(migrationId, source, …).
Default: rotating RS256 when a storage adapter is present. Because a Data.* adapter supplies a
persistent signing-key store, Klassd.Auth defaults to RS256 with auto-rotated keys and publishes JWKS +
the discovery doc — so resource servers validate via discovery with no extra setup. The first key is
generated and persisted automatically. With no store (in-memory / tests) it falls back to HS256.
To choose explicitly:
.UseRotatingRsaSigning(o => …)— the default; call it only to tuneSigningKeyLifetime/ValidationGrace, or to require RS256 explicitly (throws if no store)..UseRsaSigning(rsa)/.UseRsaSigning(pemString)— RS256 with a fixed key you supply..UseSharedSecretSigning()— opt back into HS256 (sharedSessionConfig.SigningKey, no JWKS).
Upgrading from ≤ beta.9: the default flips from HS256 to RS256 when you use a database adapter, so existing access tokens stop validating — users get a new token on next sign-in/refresh. Pin the old behavior with
.UseSharedSecretSigning()if you validate tokens with the shared secret.
Either way the public key(s) are published at /auth/jwks.json so resource servers validate
tokens without a shared secret. Email-verification tokens are likewise persisted (hashed, with a
TTL, single-use) by the storage adapter, so they survive restarts and scale across nodes.
An OpenID Connect discovery document is served at /auth/.well-known/openid-configuration
(issuer + absolute jwks_uri + signing alg), so a resource server can auto-discover keys instead of
hard-coding the JWKS URL — point JWT-bearer middleware at the auth base URL as its Authority:
builder.Services.AddAuthentication().AddJwtBearer(o =>
{
o.Authority = "https://your-host/auth"; // fetches /auth/.well-known/openid-configuration
o.TokenValidationParameters.ValidAudience = "klassd.auth"; // SessionConfig.Audience
});JWKS-based validation requires RS256 (UseRsaSigning / UseRotatingRsaSigning); the doc reports
HS256 under the default shared secret, which has no public keys to discover. The issuer reflects
SessionConfig.Issuer (default klassd.auth) — set it to your auth URL if your validator requires a
URL issuer.
Every access token carries sub and sessionHandle, plus any SessionData entries (prefixed
sd_ by default — set SessionConfig.SessionDataClaimPrefix = "" to drop the prefix). To add your
own claims — roles, tenant, plan, profile fields — register a claims enricher. It runs on every
token issue, at sign-in and on each refresh, so claims derived from live data stay current
instead of freezing at login:
// Inline — resolve whatever you need from DI per issue:
auth.AddAccessTokenClaims(async (ctx, sp, ct) =>
{
var roles = await sp.GetRequiredService<RolesService>().GetRolesAsync(ctx.UserId, ct);
return roles.Select(r => new Claim(ClaimTypes.Role, r))
.Append(new Claim("tenant", await LookupTenantAsync(ctx.UserId)));
});
// …or a class, for testability. Register as many as you like — all contribute:
auth.AddAccessTokenClaimsEnricher<MyClaimsEnricher>();public sealed class MyClaimsEnricher(IRolesService roles) : IAccessTokenClaimsEnricher
{
public async Task<IEnumerable<Claim>> GetClaimsAsync(AccessTokenClaimsContext ctx, CancellationToken ct = default) =>
(await roles.GetRolesAsync(ctx.UserId, ct)).Select(r => new Claim(ClaimTypes.Role, r));
}Enricher claims are emitted unprefixed (only SessionData gets the sd_ prefix).
When you need a claim that's only known at a point in time — e.g. captured from a third-party provider
at sign-in — write it into the session's stored payload. It then rides on the access token issued
next and on every refresh (the provider is only contacted at login, so anything you need on
refreshed tokens must be persisted, not re-fetched). This is the equivalent of SuperTokens'
sessionContainer.MergeIntoAccessTokenPayload:
// after a third-party sign-in, having created the session:
await sessions.MergeIntoAccessTokenPayloadAsync(handle, new Dictionary<string, object?>
{
["first_name"] = profile.GivenName,
["picture"] = pictureUrl,
["roles"] = profile.Roles, // string[] → a real JSON array claim
});
// pass null as a value to remove a claim.String values become string claims; arrays/objects become real JSON claims (so roles lands as a
JWT array, which the handler maps to ClaimTypes.Role so User.IsInRole / [Authorize(Roles=…)]
work). Names use SessionConfig.SessionDataClaimPrefix (set it to "" for raw names).
The full third-party pattern (mirrors the SuperTokens Azure AD example): override
IUserAccountService.ProvisionExternalAsync to persist the provider's claims (available on
ExternalUserInfo.Claims, which the default mapping fills from all provider claims) into user
metadata, then merge them onto the session — or, for claims that should always reflect live data, read
them back in an IAccessTokenClaimsEnricher. Use the merge for values fixed at login (name,
picture); use the enricher for values that can change between refreshes (roles from your own store).
When you need the provider's OAuth tokens themselves — e.g. to call Microsoft Graph for a profile
picture during sign-in, exactly like the SuperTokens AzureAdV2PostSignInUp hook — register an
IExternalSignInHook. It runs after an SSO sign-in resolves to a local user, receives the provider's
tokens, and returns any claims to add to the app cookie:
auth.AddExternalSignInHook(async (ctx, sp, ct) =>
{
// ctx.Provider, ctx.User, ctx.IsFirstSignIn, ctx.Tokens.AccessToken/IdToken/RefreshToken, ctx.Principal
var picture = await FetchGraphPhotoAsync(ctx.Tokens.AccessToken!, ct); // call the provider
await sp.GetRequiredService<IUserMetadataService>()
.SetAsync(ctx.User.Id, "profile", new { picture }, ct); // persist
return [new Claim("picture", picture)]; // → onto the cookie
});
// or a class, for testability:
auth.AddExternalSignInHook<AzureProfileHook>();The built-in providers enable token saving, so ctx.Tokens is populated (access/id/refresh + expiry,
plus the full set in ctx.Tokens.All). Use this hook for things that need the provider token at
login; use MergeIntoAccessTokenPayloadAsync / the enricher for the JWT/session path.
For the JWT/bearer flow, a KlassdSession is the sessionContainer analogue. Resolve it from the
current request (the bearer token) and merge into its payload — the equivalent of
session.GetSessionFromRequestContext(ctx).MergeIntoAccessTokenPayload(...):
app.MapPost("/me/refresh-profile", async (HttpContext http) =>
{
var session = await http.GetKlassdSessionAsync(); // from Authorization: Bearer
if (session is null) return Results.Unauthorized();
await session.MergeIntoAccessTokenPayloadAsync(new { picture = await FetchPictureAsync() });
return Results.Ok();
});KlassdSession exposes Handle, UserId, GetAccessTokenPayload(), GetClaimValue<T>(key),
MergeIntoAccessTokenPayloadAsync(...) (dictionary or anonymous object), and RevokeAsync().
You can also resolve one by handle with ISessionService.GetSessionAsync(handle).
To stamp claims onto every session as it's created — the CreateNewSession override analogue —
register a session-create hook; it's handed the live session to merge into:
auth.AddSessionCreateHook(async (session, ctx, sp, ct) =>
{
var roles = await sp.GetRequiredService<IRolesService>().GetRolesAsync(ctx.UserId, ct);
await session.MergeIntoAccessTokenPayloadAsync(new { roles });
});When you create the session yourself you can pass provider context to those hooks:
await sessions.CreateAsync(userId, sessionData: null, metadata: new Dictionary<string, object?> { ["provider"] = "azuread" });
— the hook reads it from ctx.Metadata.
For a bearer/JWT third-party flow (no cookie), map the third-party endpoints and register an
IThirdPartySignInHook. It's the closest match to the Go service's SignInUpPOST override calling
AzureAdV2PostSignInUp(userId, OAuthTokens, sessionContainer, newUser): after the code exchange and
session creation, the hook gets the provider's tokens, the new-user flag, and the live
session to merge into — and the returned access token reflects the merge.
app.MapKlassdThirdParty(); // GET /auth/thirdparty/{p}/authurl, POST /auth/thirdparty/{p}/signin
auth.AddProvider<AzureAdProvider>(); // your IThirdPartyProvider (ExchangeCodeAsync returns profile + tokens)
auth.AddThirdPartySignInHook(async (ctx, sp, ct) =>
{
// ctx.Provider, ctx.UserId, ctx.IsNewUser, ctx.Tokens (access/id/refresh), ctx.Profile, ctx.Session
var picture = await FetchGraphPhotoAsync(ctx.Tokens.AccessToken!, ct);
await sp.GetRequiredService<IUserMetadataService>().SetAsync(ctx.UserId, "profile", new { picture }, ct);
await ctx.Session.MergeIntoAccessTokenPayloadAsync(new { picture, roles = ctx.Profile.Claims.GetValueOrDefault("roles") });
});The frontend gets an authorization URL from …/authurl?redirectUri=…, redirects the user, then POSTs
the returned code to …/signin, which responds with { accessToken, refreshToken, handle, createdNewUser }.
To control how one provider's claims map to a user — the per-provider GetUserInfo analogue —
register a mapper for that scheme; it wins over the global MapExternalUser:
auth.MapExternalProfile("azuread", principal => new ExternalUserInfo(
ExternalId: principal.FindFirstValue("oid")!,
Email: principal.FindFirstValue("preferred_username"),
EmailVerified: true)
{
Claims = principal.Claims.ToDictionary(c => c.Type, c => c.Value),
});Every core service is an interface with a default implementation, and you can wrap any of them to
inject your own logic — the same model as SuperTokens' recipe-function overrides. Call
auth.Override<IService>(...) with a decorator that runs your code and calls base.X(...) for the
original. The whole suite (HTTP endpoints, cookie sign-in, webhooks) resolves these by interface, so
an override takes effect everywhere automatically.
builder.Services
.AddKlassdAuth(sessionConfig)
.UseSqlite("Data Source=auth.db")
.Override<IEmailPasswordService>((inner, sp) => new NoDisposableEmail(inner))
.Override<IUserAccountService>((inner, sp) => new DefaultRoleOnSignup(inner, sp));
// Derive from the matching ...Decorator base so you override only what you need:
public sealed class NoDisposableEmail(IEmailPasswordService inner) : EmailPasswordServiceDecorator(inner)
{
public override Task<AuthResult> SignUpAsync(string email, string password, CancellationToken ct = default) =>
email.EndsWith("@tempmail.com", StringComparison.OrdinalIgnoreCase)
? Task.FromResult(new AuthResult(false, Error: "DISPOSABLE_EMAIL_BLOCKED"))
: base.SignUpAsync(email, password, ct); // ← the original
}
// The factory hands you the IServiceProvider, so an override can pull in other services:
public sealed class DefaultRoleOnSignup(IUserAccountService inner, IServiceProvider sp)
: UserAccountServiceDecorator(inner)
{
public override async Task<User> CreateLocalAsync(string? u, string? e, string pw, CancellationToken ct = default)
{
var user = await base.CreateLocalAsync(u, e, pw, ct);
await sp.GetRequiredService<IRolesService>().SetRolesAsync(user.Id, ["member"], ct);
return user;
}
}Overrides stack — the last registered wraps the previous, so each can pre/post-process and delegate
inward. Overridable services: IEmailPasswordService, ISessionService, IThirdPartyService,
IPasswordResetService, IEmailVerificationService, IUserAccountService,
IAccountLifecycleService, IRolesService, IUserMetadataService, ITotpService — each with a
…ServiceDecorator base class.
- Sessions: short-lived access JWT (stateless) + opaque, rotating refresh token. Reusing a rotated refresh token is detected and revokes the session defensively.
- Passwords: PBKDF2-HMAC-SHA256, per-password salt. Swap for Argon2id if preferred.
- Storage-agnostic core: modules depend only on
IUserStore/ISessionStore/IUserMetadataStore; pick aData.*adapter to bind a database.
Klassd.Auth ships as NuGet packages, so an app references Klassd.Auth.Core (+ a Data.*
adapter, and Klassd.Auth.AspNetCore if it wants the ready-made endpoints) and gets both the
HTTP API and the injectable services. The UserAccountService is the union of what both apps'
existing user services expose, so it can back their current Blazor cookie sign-in:
// Replace bespoke UserService / WorkflowsUserService with the shared one:
var user = await accounts.CreateLocalAsync(username: "alice", email: null, password); // CMS (username)
var user = await accounts.CreateLocalAsync(username: null, email: "a@x.com", password); // Workflows (email)
// Passwordless user (no password) — sign-in then goes through one-time codes / passkeys:
var pl = await accounts.CreatePasswordlessAsync(username: null, "a@x.com", PasswordlessChannel.Email, verified: true);
if (accounts.VerifyPassword(user, password) && !user.Disabled) { /* issue the app's cookie */ }
await accounts.SetDisabledAsync(user.Id, true); // soft-delete (both apps)
await accounts.ProvisionExternalAsync("oidc", info, autoProvision: true); // SSO find-or-link-or-createUser carries the identity/lifecycle fields both apps share — Username (optional, CMS),
PrimaryEmail (Workflows), Disabled, and one or more LoginMethods (local password and/or
external provider). Everything app-specific lives in typed metadata, stored as one JSON doc
but accessed as typed sections so the two apps never collide:
await meta.SetAsync(userId, "cms:prefs", new CmsPrefs { Theme = "dark", Locale = "da" });
var prefs = await meta.GetAsync<CmsPrefs>(userId, "cms:prefs");
// Roles use the same mechanism (CMS has them, Workflows doesn't), via RolesService:
await roles.SetRolesAsync(userId, ["Administrator"]);
var isAdmin = await roles.IsInRoleAsync(userId, "Administrator");Each app maps these role strings to its own capability/permission model.
For the Blazor apps, add the cookie delivery and any external providers on the same builder:
var auth = builder.Services
.AddKlassdAuth(new SessionConfig { SigningKey = "..." })
.UseSqlite("Data Source=klassd-auth.db");
auth.AddKlassdAuthCookies(o =>
{
o.CookieName = "cms_auth"; // or "klassd_wf_auth"
o.SeedAdminUsername = "admin";
o.SeedAdminPassword = builder.Configuration["Seed:AdminPassword"];
o.SeedAdminRoles = ["Administrator"];
o.BypassOnLoopback = true; // Workflows-style dev bypass (optional)
});
// Microsoft Entra ID (Azure AD). tenantId can be a directory id, or "organizations"/"common".
auth.AddEntraId(
tenantId: builder.Configuration["Auth:Entra:TenantId"]!,
clientId: builder.Configuration["Auth:Entra:ClientId"]!,
clientSecret: builder.Configuration["Auth:Entra:ClientSecret"]!);
var app = builder.Build();
app.UseKlassdAuthCookies(); // wires middleware + /auth/login, /auth/logout, /auth/external/{scheme}
app.Run();This gives POST /auth/login (username-or-email + password), POST /auth/logout, and
GET /auth/external/{scheme} → provider → callback that provisions the user via
UserAccountService and issues the app cookie. Entra is OIDC under the hood (stable id from the
oid claim, name from preferred_username); add other OIDC providers with auth.AddOpenIdConnect(...).
Social OAuth 2.0 providers live in Klassd.Auth.OAuth: auth.AddGitHub(...), auth.AddFacebook(...),
auth.AddInstagram(...), auth.AddTikTok(...). Note Instagram and TikTok return no email (the
stable subject is the provider id), so they only ever explicit-link or provision a fresh account.
Original work, MIT licensed. No third-party source was read or copied; this is a clean-room implementation against a publicly documented feature set.