series redb.Route
Heads up — this is the long one. The most complete write-up of redb.Identity: from the inside, with code, no shortcuts. Budget around an 30min — it really is that long. It covers the full standards catalogue, audited line by line, the production-grade internals (live JWKS rotation with no restart, cluster-safe primitives, the password-hashing pipeline), the audit pipeline, logical-level backup, host-agnostic deployment, and the addon architecture that lets your own service sit next to Identity in the same database and transaction. Want the overview instead? There's a shorter version. Otherwise — grab a coffee.
There are three usual ways to do OAuth/OIDC in .NET, and every one of them makes you give something up.
The first is the ASP.NET-bound camp — Duende IdentityServer, ASP.NET Identity, the OpenIddict samples. Powerful, but every endpoint is an HTTP middleware. Want to call token from a worker service or a bus consumer? Spin up an HTTP listener and loop back to yourself. Want to test the issuance pipeline in isolation? Reach for WebApplicationFactory.
The second is the full IAM platforms — Keycloak, Auth0, Okta. Feature-rich, but each one is a standalone service with its own runtime, its own admin UI, its own database, its own config model, its own deployment story. Multi-tenant, sure. Embeddable, no.
The third is roll your own — and reimplement Code+PKCE, refresh-token rotation, consent storage, session revocation, MFA replay protection, JWKS rotation, key-ring sharing across replicas, and RFC 8417 back-channel logout, for the third time this decade.
redb.Identity is the missing fourth option, and it comes down to one word: transport-agnostic. The protocol is decoupled from the transport.
It is, first and foremost, a full OAuth 2.1 / OIDC server — stand it up over HTTP, expose it to external clients, discovery, JWKS, the works. It's just that internally every endpoint is a redb.Route route, not an HTTP middleware. And direct-vm:// is redb.Route's internal, cross-context transport — same family as http://, rabbitmq://, kafka://, except the network hop is swapped for a call between RouteContexts in-process.
Because of that, the same token can be reached over HTTP, over gRPC, over a queue — or, if the caller lives in the same worker, with no network at all, over that same internal transport. That last one — "no network hop, just a call from the next context" — is what gives you the embedded mode. But it's a consequence of being transport-agnostic, not the whole story: most of the time Identity runs as a normal HTTP server, and the in-process call is a nice bonus where you want it. The standards conformance and storage model are a full IS server's; the only thing that changes is which transport you reach it over.
It's open source under Apache 2.0 as of today: github.com/redbase-app/redb-identity, packages on nuget.org (redb.Identity.*, version 1.2.0). What follows is the substance — code, no marketing.
Part of the redb / redb.Route series. Identity sits on top of the whole stack, so if a layer
below is unfamiliar, these are the ones worth having open in a tab:
- The runtime it ships in — redb 3.3.0: an enterprise .NET stack you actually own, and Two routes in an evening: from a debug worker to an enterprise runtime
- The router every endpoint here is a route on — Apache Camel for .NET, dissected: the HTTP connector with no ASP.NET MVC, and the RabbitMQ connector / Kafka connector
- The store that makes "no migrations" true — REDB inside, part 1: the 13 tables the whole engine runs on and part 1.1: why they stay fast
Full list on the profile. Sources: github.com/redbase-app. About the database itself: redb.ru.
Skipping ahead: the receipts
A post like this is easy to write and hard to trust. "Full OAuth 2.1 / OIDC server" is a sentence anyone can type. So before the architecture, before a single line of code — here is the part you'd otherwise have to take on faith.
This is the official OpenID Foundation conformance suite — the same suite the OIDF uses to certify providers — run against a live redb.Identity over native HTTPS. Not our tests. Theirs.
Basic OP: 35 modules, zero failures. The 4 REVIEW modules pass — the suite just wants a human to upload a screenshot as certification evidence. The 1 SKIPPED is request objects, which we honestly don't advertise. The 1 WARNING is two extra claims in the id_token that we put there on purpose, and I explain exactly why further down rather than hiding it.
And it was no rubber stamp. The suite found real defects in our server — including PII leaking into the id_token — and they're all fixed. That story is in the conformance section, unvarnished.
One thing I won't claim: we do not carry the OpenID Certified™ mark. That's a trademark, awarded through a formal (and paid) submission to the OIDF. What's true is exactly what's on the screen — the server is run against the official suite, and the results are in the repo.
Now, the architecture.
The layers underneath, in one breath
redb.Identity doesn't stand on nothing — it stands on our ecosystem. Very briefly:
-
redb — a typed store for .NET. You write a plain POCO, tag it with
[RedbScheme], and work with it through full LINQ, server-side, with no migrations and noInclude. Providers: PostgreSQL, MSSQL, SQLite. -
redb.Route — an integration engine in the Apache Camel spirit:
From(...)….To(...)routes, 30+ connectors, enterprise integration patterns, transactions, telemetry. -
redb.Tsak — a runtime that turns those routes into a production service: dashboard, hot-reloadable modules (
.tpkg), a cluster with a coordinator.
redb.Identity is the fourth layer on top. It doesn't own a database schema and doesn't make its own HTTP stack mandatory. It takes the redb.Route engine, drops the OpenIddict pipeline onto it, and hands storage to redb. What falls out of that is the rest of this post.
All the code samples are in English (they always were); everything's in the repo, so you can reproduce it.
Killer feature #1: protocol ≠ transport
Every public endpoint in Identity is registered on a direct-vm://identity-* route. direct-vm is redb.Route's in-process, cross-context, synchronous zero-copy transport. Three things fall out of that for free.
1. Another module in the same worker calls Identity with no network at all
Picture a single Tsak worker running Identity next to your business module. The business module needs a service-account token to reach a downstream API. In the ASP.NET world that's an HTTP call to yourself over loopback. Here it's a plain in-process call:
// Inside another .tpkg module loaded into the same Tsak worker
public class CheckoutRoutes : RouteBuilder
{
private IProducerTemplate _identity = null!; // request-reply into Identity, in-process
protected override void Configure()
{
_identity = new ProducerTemplate(Context!);
_identity.Start();
From("rabbitmq:checkout.orders")
// Need a service-account access token to call a downstream API?
// Just hit Identity's token endpoint right here — same process, no socket.
.Process(async (e, ct) =>
{
var token = await _identity.RequestBody<TokenResponse>(
IdentityEndpoints.Token, // "direct-vm://identity-token"
new { grant_type = "client_credentials", client_id = "checkout-svc", /* ... */ });
e.In.SetHeader("Authorization", $"Bearer {token!.AccessToken}");
})
.To("http://pricing-api/quote");
}
}
What's happening here. The message travelling down the route is the order off the queue — that's the body. Halfway through we make a side call: ask Identity for a service-account token, take access_token out of the reply, and hang it on the current message as a header. The order itself is untouched. At the end the order — now carrying Authorization — goes off to somebody else's API.
Why the token is fetched inside .Process(...) rather than with .To(IdentityEndpoints.Token) in the DSL is a fair question, and the answer matters. Because .To() is a pipeline step: the reply becomes the new body. Write this:
From("rabbitmq:checkout.orders")
.To(IdentityEndpoints.Token) // ← the body is no longer the order, it's a TokenResponse
.To("http://pricing-api/quote"); // ← pricing-api receives the TOKEN instead of the order
— and the order is gone: the downstream API gets a JSON blob with an access_token where the cart items should be.
What you want instead is to step aside, take one field out of the reply, attach it to the current message and leave the body alone. That's the Content Enricher EIP, and RequestBody(...) inside a processor is the canonical way to express it (in Apache Camel it's exactly ProducerTemplate.requestBody). redb.Route does have a declarative .Enrich(uri, merge) — but it forwards the current exchange to the resource, so the order would land on the token endpoint, and Identity expects grant_type=client_credentials. Enrich is for "enrich this message"; ours is a side call with a different payload.
And yes, that last line really is http:// — as it should be. pricing-api is somebody else's service; you reach it over the network and no architecture changes that. The point is that there is now one network call instead of two:
| ASP.NET-bound IS | here | |
|---|---|---|
| get a token | HTTP to yourself (loopback) |
direct-vm:// — a method call, no network |
| call pricing-api | HTTP | HTTP (unavoidable) |
We removed the first one — the one that should never have existed. Making an HTTP request to your own process, serializing JSON and shaking TLS hands with yourself, is a tax you pay purely because token happens to be an HTTP middleware.
No HTTP listener. No TLS handshake. No JSON over the loopback. The exchange flows straight from your route into the Identity processor and back — same thread, same IExchange instance. Total cost: a method call plus the OpenIddict work you'd be doing anyway.
This is exactly the thing ASP.NET-bound IS servers can't do: for them token is an HTTP middleware, and calling it "from the inside" without a socket isn't a thing.
2. Facades are pure transport adapters — take the ones you need
HTTP is the first facade, not the only one and not a requirement. A facade is a thin bridge with no business logic in it:
// HTTP facade — ships in the box
From(Http.From("0.0.0.0:5000"))
.RedbController<TokenController>(); // POST /connect/token
// ↓ The controller's entire job:
// exchange.To(IdentityEndpoints.Token) // direct-vm://identity-token
// gRPC facade (planned) — same pattern
From(Grpc.Server("0.0.0.0:5001/IdentityService"))
.Filter(e => e.In.GetHeader("grpc.method") == "Token")
.To(IdentityEndpoints.Token);
// RabbitMQ RPC facade (planned)
From("rabbitmq:identity.rpc.token")
.InOut()
.To(IdentityEndpoints.Token);
Adding a transport is dropping another .tpkg that points at direct-vm://. Core never gets touched. Removing a transport is rm facade.tpkg. There's nothing in a facade to break — no logic lives there.
Under the hood the shipped HTTP facade (redb.Identity.Http) is exactly that thin: parse, forward, serialize.
// HttpFacadeRouteBuilder.cs
From($"http:POST:0.0.0.0:{port}/connect/token?inOut=true{ClientCorsParams()}")
.RouteId("http-token")
.Process(HttpIdentityProcessors.PropagateCorrelationId)
.Process(ClientAuthHttpProcessors.ExtractClientCredentials)
.To(IdentityEndpoints.Token) // ← forward into direct-vm
.Process(HttpIdentityProcessors.SerializeJsonResponse);
Want to swap HTTP for RabbitMQ entirely? In an ASP.NET stack that's a rewrite. Here it's drop the HTTP facade, keep Core.
3. A browser shows up exactly where the spec demands one
The only OAuth interaction that fundamentally needs a browser is the authorization-code redirect (and its sibling, the device-code verification URL). Everything else — token, refresh_token, introspect, revoke, userinfo, the device_code request, the management API, MFA verify, SCIM — is transport-neutral and works over gRPC, AMQP, MQ, or a direct call.
| Flow / endpoint | Needs HTTP + browser | Works on any transport |
|---|---|---|
client_credentials (M2M) |
— | ✅ |
authorization_code — redirect to /authorize
|
✅ | — |
authorization_code — code → token exchange |
— | ✅ |
refresh_token, revoke, introspect, userinfo
|
— | ✅ |
device_code — initial request |
— | ✅ |
device_code — user verification URL |
✅ | — |
| Management API, SCIM 2.0, audit query | — | ✅ |
The same TokenEndpointProcessor runs whether the call came in over HTTP, gRPC, RabbitMQ, or a direct direct-vm call from the next module. One pipeline, one set of tests, one audit trail. In practice that also buys you cheap integration tests (most of the 1768-test suite drives direct-vm and never spins up Kestrel) and multi-port isolation — public OIDC endpoints on one port, the management API and SCIM on another, firewalled independently, same pipeline behind both.
Killer feature #2: no migrations
This one comes up from redb, but it lands especially well for an identity server. redb.Identity doesn't own a schema. It sits on redb, where a "table" is a plain C# class with an attribute. Here's a user:
// src/redb.Identity.Core/Models/UserProps.cs
[RedbScheme("identity.user")]
public class UserProps
{
// Standard OIDC profile claims
public string? GivenName { get; set; }
public string? FamilyName { get; set; }
public string? Picture { get; set; }
public bool EmailVerified { get; set; }
// Structured OIDC address (§5.1.1) — a nested redb object, not a JSON blob
public AddressClaim? Address { get; set; }
// Arbitrary tenant claims — each pair becomes its own props row,
// queryable and indexable without an ALTER TABLE.
public Dictionary<string, string>? CustomClaims { get; set; }
// Multi-provider federation links — native props rows, hot reverse lookup
// via RedbObject.value_string = "{providerId}:{sub}".
public Dictionary<string, ExternalIdentity>? ExternalIdentities { get; set; }
public string? ScimExternalId { get; set; } // RFC 7643 §3.1
}
Need to add a field to your user — a LoyaltyTier, a ManagerSubject, a DepartmentCode? You literally add it to the class. No migration, no ticket to a DBA, no downtime. redb reads and writes it the next instant; once it's in production data, queries can filter and project it.
A word on CustomClaims. That's not an nvarchar(max) blob or a jsonb column you later hand-index with GIN. It's a Dictionary<string,string> on props, and every key is its own queryable, indexable row. ContainsKey, the indexer, nested access — all of it runs as native LINQ server-side. A typical EF-Core identity server keeps custom claims either as a blob or normalized into eight satellite tables with joins.
There's a hot/cold split too: login, password hash, and status live in the relational _users table (hot keys, narrow indexes), while the cold OIDC profile lives in props rows linked by RedbObject.key = _users._id. No over-indexed wide rows, no JSON-blob lookups on the hot path.
The whole thing is composed from 24 typed redb schemes (identity.application, identity.scope, identity.token, identity.session, identity.user, identity.group, identity.mfa, identity.webauthn_*, identity.federation_provider, identity.claim_mapper, identity.dpop_consumed_jti, identity.signing_key, …). All plain C# classes. InitializeAsync() syncs the schema. No migration files.
RTTI at the database level — what the storage model actually is
Most identity servers spread storage across half a dozen moving parts: EF Core + migrations for users/apps/claims, Redis for session cache and MFA OTP, the filesystem or Azure Key Vault for the DataProtection key ring, a separate table for signing keys, Elasticsearch for the audit log, and something else again for rate-limit counters. Each part is its own client, its own init, its own health check, its own backup plan, its own migration story.
Here a single store — REDB — handles all of it, because it keeps typed data schemes declared in the database itself — runtime type information at the DB level:
| Language concept | In REDB |
|---|---|
class Foo { int X; string Y; } (type declaration) |
a row in _schemes describing the fields |
new Foo { X = 42, Y = "bar" } (instance) |
a row in _objects + typed slots in _values
|
typeof(Foo) / obj.GetType() (RTTI) |
_objects._id_scheme → _schemes._id |
where T : class, new() |
the C#-side RedbObject<TProps> proxy |
The C# classes (UserProps, ApplicationProps, …) are just a type-safe view over the schemes. What that buys the identity server:
One query language for everything. Finding a user, a token by reference_id, an authorization by subject GUID — one IRedbService, one LINQ provider, one transactional context:
await _redb.Query<UserProps>()
.Where(u => u.EmailVerified)
.WhereRedb(o => o.Key == coreUserId)
.FirstOrDefaultAsync();
await _redb.Query<TokenProps>()
.WhereRedb(o => o.ValueString == referenceId)
.FirstOrDefaultAsync();
Every OpenIddict store (RedbApplicationStore, RedbAuthorizationStore, RedbTokenStore, RedbScopeStore) plus ours (PropsSigningKeyStore, PropsServerSideOtpStore, PropsWebAuthnChallengeStore, PropsPasswordHistoryStore) sits on one interface.
One backup story. Back up Postgres and you've backed up the entire identity stack — keys (DataProtection-encrypted in the same DB), sessions, signing keys, audit. There's no "oh, and that one component is still in Redis, back that up separately."
Multi-tenant isolation via schemes. Want tenant A on one shape of UserProps and tenant B on a slightly different one? Per-tenant schemes — the split is physical, through _id_scheme.
Pro mode with PVT. The Pro build ships Precomputed Value Tables — materialized indexes for hot-path queries. A lookup on value_string == clientId goes through the PVT index instead of an unrolled join over _values. Details in the REDB indexing post.
Killer feature #3: the same server — also as an embedded library
This one falls straight out of the first two: once the protocol is decoupled from the transport and direct-vm:// is the internal cross-context transport, one of the available modes is no network facade at all. Not a replacement for "a normal HTTP server" — an extra option for when Identity lives in the same worker as whatever calls it.
"Stand up an identity server" usually means run a separate process: its own runtime, its own port, its own admin, its own DB, its own deploy. Even the "embeddable" ASP.NET libraries still need an HTTP pipeline to call them.
With redb.Identity you can take just the engine — redb.Identity.Core — and zero facades. No Kestrel, no HTTP. You reference the package, bring up the OpenIddict pipeline inside your process, and hit the endpoints over direct-vm:// from your own code:
// In your app: you need a client token for an internal call —
// no network hop, no Identity HTTP server standing up at all.
var producer = new ProducerTemplate(ctx); // ctx — your IRouteContext
producer.Start();
var token = await producer.RequestBody<TokenResponse>(
IdentityEndpoints.Token, // "direct-vm://identity-token"
new { grant_type = "client_credentials", client_id = "internal-svc", client_secret = secret });
That changes the embedding model. The identity server stops being "a service next door" and becomes a library inside your process — one that happens to have a full OAuth 2.1 / OIDC engine under it, but you talk to it with a method call, not a network request. When the day comes to expose those same endpoints to external clients, you add an HTTP facade as one .tpkg, and the exact same processors start answering over HTTP too. Core doesn't change.
Neither Duende, nor Keycloak, nor Auth0 does this: the first has HTTP-middleware endpoints, the rest are external services with a network hop.
Two contexts, one worker
Architecturally it's two RouteContexts inside one Tsak worker:
┌───────────────────────────────────────────────────────────────────────┐
│ Tsak worker │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ RouteContext "identity" ← redb.Identity.Core.Module.tpkg │ │
│ │ ───────────────────────────────────────────────────────────── │ │
│ │ • ~50 direct-vm:// routes │ │
│ │ • OpenIddict server pipeline (token, authorize, userinfo, ...) │ │
│ │ • redb stores: Users, Apps, Scopes, Tokens, Sessions, Audit │ │
│ │ • DataProtection key-ring (RedbXmlRepository) │ │
│ │ • Signing keys (RSA 2048, encrypted at rest) │ │
│ │ • MFA: TOTP / SMS-Email OTP / WebAuthn / recovery codes │ │
│ │ • cleanup timers (.Cluster(true) → leader-only in a cluster) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ▲ direct-vm:// (synchronous, in-process) │
│ │ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ RouteContext "identity.http" ← redb.Identity.Http.tpkg │ │
│ │ • Kestrel → redb.Route.Http → RedbController dispatcher │ │
│ │ • /connect/token, /authorize, /userinfo, /introspect, ... │ │
│ │ • /api/v1/identity/* (management), /me/* (self-service) │ │
│ │ • /scim/v2/Users, /Groups, /Bulk │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ Your modules → call IdentityEndpoints.Token / .ManageUsers / ... │
│ over direct-vm:// directly, no HTTP │
└───────────────────────────────────────────────────────────────────────┘
│
▼
redb store (PostgreSQL / MSSQL / SQLite)
Worth calling out: project-reference isolation. The HTTP facade compiles without seeing a single type from redb.Identity.Core:
redb.Identity.Http ─project-ref→ redb.Identity.Contracts ✅
redb.Identity.Http ─project-ref→ redb.Identity.DataProtection ✅
redb.Identity.Http ─project-ref→ redb.Identity.Core ❌ FORBIDDEN
The facade only sees wire DTOs and endpoint constants (redb.Identity.Contracts) plus a couple of SPI interfaces. Core's internal types never leak across the ABI boundary — CI fails the build if a using redb.Identity.Core; ever shows up under redb.Identity.Http/. That's what makes the ".tpkg story" honest: the two packages build and version independently.
Anatomy of a token request
Since every endpoint is a route, it's worth seeing what one is made of. The token endpoint isn't a "controller method" — it's a declarative redb.Route pipeline:
// IdentityCoreRouteBuilder.tokenRoute (RouteId: identity-token)
WithRedbTx(...) // atomic write boundary (one tx per issuance)
.Process(trustedProxy) // sanitize X-Forwarded-For BEFORE rate-limit
.Process(perIpThrottle) // per-IP limit (optional)
.OnException<InvalidOperationException>() // RFC 6749 error → HTTP status mapping
.Throttle(clientId, ...) // per-client token bucket
.Traced("identity.token-request")
.Metered(...,
new TokenEndpointProcessor(handler, timeProvider)) // ← OpenIddict pipeline
// ↑ inside: redb stores — RedbTokenStore, RedbApplicationStore, ...
.EndTraced()
.WireTap("direct-vm://identity-events"); // audit + multicast, fire-and-forget
Three things are visible here that ASP.NET usually smears across middleware, filters, and attributes: the transactional boundary (WithRedbTx — the token is issued and persisted whole, or it rolls back), rate limiting before the request reaches the expensive work, and audit as a branch (WireTap) that can't block issuance or take it down. Same TokenEndpointProcessor, whether the call arrived over HTTP, gRPC, or direct-vm. One pipeline, one audit trail, one test suite.
Storage: what it buys you over EF-Core-on-tables
| Property | redb.Identity | Typical EF-Core identity server |
|---|---|---|
| Storage engines | PostgreSQL, MSSQL and SQLite from one codebase — swap by the provider package | one provider per build; switching means rewriting the EF model + migrations |
| Schema evolution | no migrations. Add a field to a *Props class — the scheme picks it up on next InitializeAsync
|
generate a migration, review the SQL, run Update-Database, hope rollback works |
| Custom claims / tenant extensions |
Dictionary<string,string>? CustomClaims — each key is a queryable, indexable row |
jsonb/nvarchar(max) blob; you write the GIN / computed-column indexes |
| Multi-provider federation links |
Dictionary<string, ExternalIdentity> — native props rows, hot reverse lookup |
one-to-many join table, scaffolding per provider |
| Hot/cold split | hot keys in relational _users; cold profile in props linked by key = _users._id
|
either one wide table or eight satellite tables |
| Multi-tenant data isolation |
GetRedbService("identity") — the named instance can target a separate DB / schema / connection |
single DbContext; isolation means separate ASP.NET apps |
Providers: zero code change between rows
| Engine | OSS package | Pro package |
|---|---|---|
| PostgreSQL 13+ | redb.Postgres |
redb.Postgres.Pro |
| Microsoft SQL Server 2019+ | redb.MSSql |
redb.MSSql.Pro |
| SQLite 3.44+ | redb.SQLite |
redb.SQLite.Pro |
Identity references only the redb.Core OSS abstraction — the host worker picks the provider, Identity code never names one. And this isn't a claim: the same test suite (1768 tests) is green on all three — Passed: 1767, Skipped: 1, Failed: 0 on PostgreSQL, MSSQL and SQLite, flipped with a single REDB_PROVIDER env-var. (The one skip is a known host-teardown probe on PG, not a product gap.)
Standards — the full catalogue
No skimping here, because standards conformance is why you reach for an IS server in the first place. Close to 40 standards — not "mentioned in the code," but implemented: every assertion sits next to its spec reference, and every contract has a demo_*.ps1 probe against a live server.
Before publishing, we audited this catalogue line by line — against the code, against live discovery, against the demos. Several rows did not survive: some are now worded precisely, some were struck and moved to the "what's missing" section below, and three turned out to be cheaper to build than to delete — which is where the claims parameter, the SCIM Enterprise extension and loopback redirects came from. The limitations list at the end isn't a disclaimer; it's the output of the same audit. Anything we overclaimed here, you could disprove from our own /.well-known/openid-configuration in ten seconds.
OAuth 2.0 / 2.1 core
| RFC / spec | What | Demo |
|---|---|---|
| RFC 6749 OAuth 2.0 Framework | grant types, scopes, error responses |
demo_client_credentials.ps1, demo_authcode_pkce.ps1, demo_refresh_rotation.ps1, demo_password_ropc.ps1
|
| RFC 6750 Bearer Token Usage |
Authorization: Bearer, WWW-Authenticate: Bearer challenge |
demo_userinfo.ps1 — asserts WWW-Authenticate on missing/empty/garbage/tampered |
| RFC 6585 Additional HTTP Status Codes | 429 Too Many Requests + Retry-After |
demo_throttle_rfc6585.ps1 — parallel burst + recovery + per-key isolation |
| RFC 7009 Token Revocation | /connect/revoke |
demo_introspect_revoke.ps1 |
| RFC 7521 Assertion Framework | assertions for client authentication (client_assertion). No assertion grant types (jwt-bearer, saml2-bearer) |
covered via RFC 7523 |
| RFC 7523 JWT Bearer Assertion (private_key_jwt) | client assertion on token / introspect / revoke / par / device |
demo_private_key_jwt.ps1 — DCR with inline JWKS + token + introspect + tamper-negative |
| RFC 7591 Dynamic Client Registration | POST /connect/register |
demo_dcr_lifecycle.ps1 |
| RFC 7592 DCR Management Protocol | GET/PUT/DELETE /connect/register/{client_id} |
demo_dcr_lifecycle.ps1 (same file) |
| RFC 7636 PKCE | code_verifier / code_challenge / S256 (plain rejected per OAuth 2.1) |
demo_authcode_pkce.ps1 |
| RFC 7662 Token Introspection | /connect/introspect |
demo_introspect_revoke.ps1 |
| RFC 8252 OAuth 2.0 for Native Apps | PKCE-required public clients + §7.3 loopback redirect: the port is not compared (without it no az login-style CLI can work — they get an ephemeral port from the OS at launch) |
demo_loopback_redirect.ps1 — 14 assertions, most of them negative |
| RFC 8414 Authorization Server Metadata | /.well-known/oauth-authorization-server |
demo_discovery_jwks.ps1 |
| RFC 8628 Device Authorization Grant |
/connect/deviceauthorization, 5s polling |
demo_device_code.ps1 + non-interactive demo_device_code_ci.ps1 for CI |
| RFC 8693 Token Exchange | impersonation + delegation chain (act). Opt-in; subject_token_type is access_token only |
demo_token_exchange.ps1 — positive + negative |
| RFC 8725 JWT BCP | algorithm allow-listing for DPoP proofs and client assertions; none is rejected |
demo_dpop.ps1 (DPoP proof), discovery (*_auth_signing_alg_values_supported) |
| RFC 9068 JWT Profile for Access Tokens | typed at+jwt. Access tokens are JWE-encrypted by default — a resource server either introspects, or you flip DisableAccessTokenEncryption for local validation |
token header visible in demo_jwt.ps1
|
| RFC 9126 Pushed Authorization Requests (PAR) |
/connect/par, per-client require_pushed_authorization_requests
|
demo_par.ps1 + demo_par_per_client.ps1
|
RFC 9207 Authorization Response iss
|
iss in the auth response |
demo_auth_extras.ps1 |
| RFC 9449 DPoP | proof-JWT validation, replay cache, DPoP-Nonce, resource-server validator |
demo_dpop.ps1 |
OpenID Connect 1.0 family
| Spec | What | Demo |
|---|---|---|
| OIDC Core 1.0 | code flow (OAuth 2.1 — no implicit, no hybrid). id_token (RS256), sub/aud/exp/iat, nonce, prompt, max_age, acr_values, claim mappers |
demo_discovery_jwks.ps1, demo_claim_probes.ps1, demo_prompt_max_age.ps1, demo_acr_values.ps1
|
OIDC Core §5.1 full profile set |
all 14 claims; updated_at a JSON number, *_verified JSON booleans
|
demo_claim_probes.ps1 |
| OIDC Core §5.4 claim delivery | scope-derived claims (profile/email/phone/address) are served from UserInfo, not baked into the id_token — the id_token stays a statement about the authentication event, not a PII container |
demo_claim_probes.ps1 — asserts the absence of PII in the id_token |
OIDC Core §5.5 claims parameter |
an RP names the exact claims it needs instead of pulling the whole profile scope to get one name, and picks the channel: userinfo or id_token. essential / value / values supported |
demo_claims_parameter.ps1 — 14 assertions, negatives included |
| OIDC Discovery 1.0 |
/.well-known/openid-configuration + JWKS |
demo_discovery_jwks.ps1 + manual RS256 verification |
| OIDC Dynamic Client Registration 1.0 | RFC 7591 extensions for OIDC client_metadata | demo_dcr_lifecycle.ps1 |
| RP-Initiated Logout 1.0 |
/connect/logout + post_logout_redirect_uri
|
demo_logout_endsession.ps1 |
| Back-Channel Logout 1.0 |
backchannel_logout_uri + JWT logout token + pull-based revoked-SID feed for multi-replica RPs |
demo_backchannel_logout.ps1 |
Validated by the official OpenID Foundation conformance suite — not just our own tests
A catalogue is a claim. The proof is running the very conformance suite the OpenID Foundation uses to certify providers — not our tests, not "works on my machine," the official suite against a live server. Most .NET OAuth/OIDC implementations have never seen it; we drive redb.Identity through it as a real OP.
Basic OP — 35 modules, 0 failures:
| Result | Modules | What it means |
|---|---|---|
| PASSED | 29 | green |
| REVIEW | 4 | the suite wants a human-uploaded screenshot (login form, error page) — counted as passing |
| WARNING | 1 | two extra claims in the id_token — a deliberate extension of ours, discussed below |
| SKIPPED | 1 | request objects (RFC 9101) — we honestly don't advertise them, so the suite skips |
| FAILED | 0 | — |
The Config OP profile is clean too (native HTTPS, no reverse proxy).
The suite is unforgiving: it checks exactly the RFC requirements hand-rolled servers trip on. And it was no rubber stamp — it found real defects. Here they are, unvarnished:
- authorization errors are delivered only to the client's registered
redirect_uri(RFC 6749 §4.1.2.1) — an error open-redirect, closed, not "redirect to whatever was sent"; - a reused authorization code returns
400 invalid_grant, not401 invalid_token(§5.2); -
Cache-Control: no-storeon token / introspection / revocation (§5.1); -
email_verified/phone_number_verifiedare JSON booleans, not strings (OIDC §5.1); -
prompt=login/max_ageroute to/loginand complete on re-login; the re-auth marker is bound to the session id — no redirect loop, no same-second bypass; -
PII was leaking into the id_token. Claims from the
profile/email/phone/addressscopes were baked straight into the id_token, when in the code flow they belong in UserInfo (OIDC §5.4). An id_token gets forwarded to third parties and logged as proof of the sign-in event, so the user's phone number travelled a great deal further than the RP ever intended. It doesn't any more; - UserInfo, conversely, was returning too much — the token's own plumbing: OpenIddict's
oi_*internals,jti/exp/iat/at_hash. Those describe the token, not the user; UserInfo (§5.3) must return the user's claims and nothing else; -
/connect/userinfodidn't accept the access token in the POST body (RFC 6750 §2.2); - the
profileclaim set was incomplete — the suite diffs UserInfo against exactly the §5.1 list and flags every claim you omit.
The suite also raised a WARNING on oidcc-claims-essential: "name not found in userinfo". We dug in: the test asks for name through the claims parameter (§5.5) — which we had not implemented at all, and our own discovery document admitted it ("claims_parameter_supported": false). So we built it.
Two warnings we kept on purpose
The very first module, oidcc-server, finishes with two warnings:
WARNING EnsureIdTokenDoesNotContainNonRequestedClaims
id_token contains non-requested claim 'oi_tkn_id'
WARNING EnsureIdTokenDoesNotContainNonRequestedClaims
id_token contains non-requested claim 'redb:user_id'
We're not going to bury those. Here's why they're there and why they stay.
First, the important bit: these are warnings, not failures. OIDC Core does not forbid additional claims in an id_token. The suite warns because an extra claim can mean user data is leaking — and its own message concedes the alternative: "…or that it implements an extension the conformance suite is not aware of." That's our case.
Neither claim is data about the user:
-
oi_tkn_id— the id of the token's entry in the store. It is what makes the id_token revocable, and what drives back-channel logout. Removing it means giving up id_token revocation. That trades a real capability for a clean warning list, and we declined the trade. -
redb:user_id— our own private claim, namespaced per the RFC 7519 §4.3 convention. The publicsubis a GUID (stable, correct across instances); the hot key in the relational_userstable is a bigint. This claim lets a client decode the id_token and join the user to its own table by our internal id, without a round-trip back to us.
And here's the part that proves this isn't a rationalisation. In that same run we deleted a third such claim from the id_token. OpenIddict was also stamping oi_au_id in there — its internal link to the authorization entry. That one has no business in a token handed to a client: it means nothing to them, and there was no defending it. Gone (kept on the access_token, where introspection needs it).
So we didn't wave the warnings away. We went through each one and kept exactly the two we can answer for.
That's the line between "wrote OAuth over a weekend" and a server that clears the same checks industrial IdPs do. Full per-module breakdown and local setup: OPENID_CERTIFICATION.md in the repo.
To be scrupulous: we do not carry the OpenID Certified™ mark. That's a trademark, granted by the OIDF through a formal submission. What we claim is exactly what's true — the server is run against the official OIDF conformance suite, and the results are in the repo.
JOSE / JWT
RFC 7515 (JWS), 7517 (JWKS), 7518 (JWA), 7519 (JWT), 7638 (JWK thumbprint, used as the DPoP jkt), 7800 (cnf / proof-of-possession), 8176 (amr).
SCIM 2.0
| RFC | What | Demo |
|---|---|---|
| RFC 7643 SCIM Schema | User, Group, Meta | demo_scim.ps1 |
| RFC 7643 §4.3 Enterprise User extension |
department, manager, employeeNumber, costCenter, organization, division. manager is a complex attribute; $ref and displayName are derived, not stored (a stored copy would rot the moment the manager is renamed) |
demo_scim_enterprise.ps1 — 24 assertions |
| RFC 7644 SCIM Protocol | CRUD + ETag concurrency (RFC 7232 → 412) + discovery. Filter is single-expression: userName / externalId / displayName / emails.value, no and/or. Bulk is opt-in (Features.EnableScimBulk, off by default — and the ServiceProviderConfig says so honestly) |
demo_scim.ps1 (CRUD) + demo_scim_bulk.ps1 (bulk) + demo_scim_etag.ps1 (concurrency) |
The Enterprise extension is what corporate provisioning sends on its very first request: Okta, Entra ID and Workday all push department and manager on the initial sync. A provider that advertises only the core schema makes them drop that data on the floor.
The SCIM discovery endpoints (/scim/v2/ServiceProviderConfig, ResourceTypes, Schemas) are served unauthenticated and unconditional, so an RP can probe them before SCIM provisioning is switched on.
MFA / WebAuthn / OTP
| Standard | What | Demo |
|---|---|---|
| RFC 6238 TOTP | time-based OTP, atomic + replay-protected per §5.2 (160-bit secret per RFC 4226 §4) | demo_mfa_totp.ps1 |
| W3C WebAuthn Level 2 / FIDO2 | passkey enrollment + assertion, attestation=none/direct/indirect/enterprise, userVerification variants |
API ready, demo waits on a frontend companion |
| NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1 | password policy (min 12, upper+lower+digit, history) |
demo_password_change_negatives.ps1 13/13 |
| Argon2id (OWASP 2023 primary) | 64 MiB / 3 iterations / parallelism 4 | enforced, upgrade-on-login for legacy BCrypt |
SMS/Email OTP and recovery codes (one-shot, consumed in the same transaction that creates the session) — demo_mfa_recovery_codes.ps1. Disable / replace a method — demo_mfa_disable_replace.ps1.
Federation, self-service, admin
External OIDC IdPs (Google, Microsoft, Keycloak) plus a dedicated GitHub OAuth2 path (no discovery, no id_token — profile and emails pulled from the REST /user and /user/emails, endpoints configurable for GitHub Enterprise / Gitea). Provision-on-first-login, link-on-replay (same sub → same user, no duplicate), self-service link/unlink via /me/federated-identities/*, email-conflict resolution that refuses a silent takeover. Demos: demo_federation.ps1, demo_federation_e2e.ps1 (full round-trip against navikt/mock-oauth2-server), demo_federation_github.ps1, demo_federation_link_unlink.ps1. LDAP / Active Directory is a separate redb.Identity.Ldap package with bind-on-login and UserAccountControl parsing.
Self-service and admin round it out: registration + email verify, profile and email change with dual-confirm, password change (13 negatives) and forgot-password round-trip, sessions list/revoke (own and admin, with dry-run), self-service account delete with cascade, OIDC client CRUD, a granular scope gate (identity:audit.read is distinct from identity:read is distinct from identity:users.manage), group hierarchy with per-member role labels, and the emergency-admin bootstrap endpoint. Each has its own demo.
Two that deserve a shout because they're rare in .NET: DPoP (RFC 9449) and private_key_jwt (7521/7523) — both implemented, both pinned by demo probes, not roadmap items.
Production-grade features — the "later" list, done now
The unglamorous things that decide whether an identity server survives production.
Live JWKS rotation — no process restart
The classic problem: you shipped an OIDC server, 60 days pass, you need to rotate the signing key (RP JWKS caches live up to a week, and then there are incidents). Most implementations — vanilla OpenIddict included — need a process restart to rotate, because OpenIddictServerOptions.SigningCredentials is built once via IPostConfigureOptions<> and cached.
Here:
- An admin hits
POST /signing-keys/rotate(requires scopeidentity:applications.manage). -
PropsSigningKeyStore.RotateAsyncpersists the new key and marks the old one demoted — but it stays in the JWKS until retire, a grace window for in-flight tokens. -
IOptionsMonitorCache<OpenIddictServerOptions>and…ValidationOptionsare invalidated immediately. - The next OIDC handler re-evaluates the whole Configure → PostConfigure chain, and
PropsSigningKeyStoreOpenIddictPostConfigurerebuildsSigningCredentialsfrom the current store snapshot. - New tokens are signed under the just-rotated kid right away; in-flight tokens validate under the old key until it's retired.
demo_jwks_rotation.ps1 asserts the full grace-window flow: after rotate the JWKS serves K1 + K2 (old-in-grace + new-active); new id_tokens carry the just-rotated kid; after DELETE /signing-keys/{K1} the JWKS holds only K2 and old tokens stop validating; K1 stays in the admin audit list with inJwks=false for the compliance trail. All without a restart.
Cluster-safe primitives — built in
It's an identity server; it'll live in a cluster. Baked in:
-
Optimistic concurrency on every
RedbObject<T>via a_hashfield.UpdateAsynctakes a row lock, checks the hash, and throwsOpenIddictExceptions.ConcurrencyExceptionon a mismatch. A test drives exactly that with two concurrent writes. -
A cluster-wide lock for schema init. When several workers come up at once,
IdentitySchemaInitListenertakesLockForUpdateon a specific row — "leader under a cluster lock" — initializes the schemes, and releases. Followers seeanother node holds the lock, proceeding idempotentlyand wait. -
Atomic claim for background tasks. Trash purge and orphaned-task recovery go through
TryClaimOrphanedTaskAsync— an atomic UPDATE with a WHERE condition. Whichever worker flipspending → runningfirst owns the work. No distributed locking needed. -
Per-request cache invalidation via the DI scope.
RedbApplicationStore._clientIdCachelives for exactly one HTTP request and is invalidated on CRUD — no stale data across requests.
Background deletion — the DB as a queue
Deleting an OIDC application with thousands of accumulated tokens and authorizations is an expensive cascade. Do it on the request thread and the user times out; do it through an in-memory channel and you lose it on a worker crash. Here:
-
SoftDeleteAsyncsynchronously re-parents the object under a trash scheme (scheme_id = -10). It instantly disappears from everyQuery<>(). The RP sees 204, the operation "succeeded." - A
BackgroundDeletionServicepolls the DB for pending trash containers every 5 seconds. Cluster-safe (TryClaimOrphanedTaskAsync); on a worker crash the next poll cycle picks it up. - The purge runs in batches of 10 with a
Task.Delay(50ms)between them, so live traffic can interleave.
No in-memory state — a crash mid-operation loses nothing; the DB stays the source of truth.
DataProtection key ring on the storage layer
The cookie session (SameSite=Strict), federation state, MFA setup tokens, the recovery-code pepper — all signed or encrypted via ASP.NET Core DataProtection. By default DataProtection keeps keys on the filesystem or in Azure Storage, which isn't cluster-safe out of the box and needs its own operational care. Here the key ring lives in the same REDB store via RedbXmlRepository: back up Postgres and the ring comes with it; every cluster node sees the same keys (rotation streams through REDB notifications too); the keys are encrypted at rest by a configurable master key (AES-GCM, certificate-based, or a custom KMS factory).
Rate limiting with a distributed backend
/connect/token, /login, /mfa/verify, /mfa/recovery are all throttled — per-IP and per-(IP+username) counters. The backend is an option: memory (single-node in-memory bucket) or redis (cluster-wide via StackExchange.Redis, one Redis for rate limits + the federation-state nonce store). 429 + Retry-After is exactly to spec; demo_throttle_rfc6585.ps1 runs a parallel burst, recovery after the window, and isolation between keys.
Password hashing — Argon2id and BCrypt side by side, with upgrade-on-login and a timing-attack fix
The details that separate a production hashing pipeline from a tutorial one. Two algorithms run in parallel, no deployment-level either/or.
Argon2id — the primary for new deployments. Defaults straight from the OWASP Password Storage Cheat Sheet 2023:
new Argon2idPasswordHasher(
memoryKib: 65536, // 64 MiB
iterations: 3,
parallelism: 4,
saltBytes: 16,
hashBytes: 32);
A memory-hard function: 64 MiB per verify makes GPU/ASIC attacks uneconomical compared to BCrypt (compute-hard but memory-cheap, so a mass FPGA attack works). Argon2id is the hybrid that won the Password Hashing Competition in 2015.
BCrypt — kept for legacy and compliance-driven deployments. It's been in production since 1999; an auditor sees a familiar algorithm with known failure modes, and the PCI-approved libraries allow it outright. And there's the migration story: if you're moving off IdentityServer / ASP.NET Identity, your DB already holds BCrypt hashes — we read them natively, no forced password reset on 100k users.
Upgrade-on-login — a transparent migration. Every successful login checks whether the stored hash matches the current OWASP parameters. If not, a fire-and-forget rehash into the fresh format:
// LoginService.cs (simplified)
if (_passwordHasher is not null && NeedsRehash(_passwordHasher, coreUser.Password))
{
_ = Task.Run(async () =>
{
using var rehashScope = scopeFactory.CreateScope();
var rehashRedb = rehashScope.ServiceProvider.GetRequiredService<IRedbService>();
var freshUser = await rehashRedb.UserProvider.GetUserByIdAsync(userId);
await rehashRedb.UserProvider.SetPasswordAsync(freshUser, capturedPassword, ...);
});
}
Over N logins the whole base drifts onto Argon2id — no forced reset, no migration window.
Constant-time fake-verify — against user enumeration by timing. ValidateUserAsync returns null for three failure modes: user not found, user disabled, wrong password. Without mitigation (1) and (2) return in 5–10ms (the DB lookup misses immediately) while (3) takes ~250ms (BCrypt actually runs), and an attacker can tell "does this email exist" from the response time — that's CWE-204. A precomputed FakeBcryptHash (workFactor 12) runs on every negative path, so the wall-clock cost is identical across all three:
// LoginService.AuthenticateLocal — fake-verify to keep timing consistent
if (coreUser is null)
{
try { _ = BCrypt.Net.BCrypt.Verify(password ?? "", FakeBcryptHash); } catch { }
_securityLogger.LogWarning("Login denied: user '{Username}' — invalid credentials or not found", username);
return LoginResult.Failed("Invalid credentials.");
}
Password policy — NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1, enforced before hashing:
| Setting | Default | Configurable |
|---|---|---|
MinLength |
12 | ✅ |
RequireDigit / RequireUppercase / RequireLowercase
|
true | ✅ |
RequireSpecial |
false | ✅ |
HistoryCount (can't reuse the last N) |
5 | ✅ |
MaxAge (force rotation) |
90 days | ✅ |
BreachCheckEnabled (haveibeenpwned probe) |
false | ✅ |
HistoryCount lives in PropsPasswordHistoryStore — SHA-256 hashed, peppered prior-password digests in REDB, which closes the "change the password, come back an hour later to the old one" gap. demo_password_change_negatives.ps1 runs all 13 negative paths.
Audit — "anywhere," proven by integration tests
Audit is first-class, not a bolt-on. 116 typed events across 9 categories, single source of truth in IdentityAuditEventIds. Each event lands in a relational identity_audit_log table (user_id BIGINT, indexed — a direct integer seek, not a scan) and, if configured, multicasts to external sinks. Plaintext secrets are never written — a rotation logs a ClientSecretRotated marker only.
| Category | Count | Examples |
|---|---|---|
authentication |
4 |
UserLoggedIn, LoginFailed, PasswordChanged
|
authorization |
14 |
TokenIssued/Revoked/Introspected, ConsentGranted, DpopReplayDetected
|
admin |
22 |
Client*, Scope*, User*, Group*, ClaimMapper*
|
federation |
9 |
FederatedUserLoggedIn, FederationStateValidationFailed, FederatedEmailConflict
|
mfa |
11 |
MfaEnrolled, MfaVerifyFailed, MfaWebAuthnSignCounterAnomaly
|
scim |
9 |
ScimUserCreated/Replaced/Patched, ScimBulkProcessed
|
system |
9 |
SessionRevoked, SidRevoked, TokensRevokedByUser
|
First candidates for a SIEM: LoginFailed, MfaVerifyFailed, MfaWebAuthnSignCounterAnomaly, DpopReplayDetected, FederationStateValidationFailed, ClientSecretRotated, AllSessionsRevoked.
The mechanism: a WireTap on direct-vm
Every mutating action (login, token issue, scope grant, MFA enroll, federation link, user delete, …) goes through EventDispatchProcessor, which writes a typed IdentityEvent into exchange.Out.Body + headers. From there it's a standard redb.Route WireTap:
WithRedbTx(From(IdentityEndpoints.Token))
.Process(...)
.WireTap(IdentityEndpoints.Events); // ← a copy branches off to the audit pipeline
The identity-events route fans out to any number of sinks at once — without touching identity code, it's route config.
What the tests actually prove
"Anywhere" isn't marketing. Nine different audit targets each have their own integration test in redb.Identity.Tests/Audit/, run against real containerized brokers in the dev compose stack:
| Sink | Test | What it checks |
|---|---|---|
| SQL (Postgres / MS SQL table) | AuditSqlIntegrationTests |
event lands with the right columns, parameter binding via the redb.Route SQL connector |
| PROPS store (typed storage in the same REDB) | AuditEavPersistenceIntegrationTests |
IdentityEvent saved as RedbObject<AuditEventProps> next to identity data — queryable |
| Kafka | AuditKafkaIntegrationTests |
publish to a topic, partition key from user_id / event_type, acks=all |
| RabbitMQ | AuditRabbitMqIntegrationTests |
publish to an exchange, routing key from event-type, durable queue with manual ack |
| IBM MQ / WMQ | AuditIbmMqIntegrationTests |
publish to an MQQueue, MQMD with correlation-id, transactional context |
| AMQP 1.0 | AuditAmqpIntegrationTests |
publish to a standard AMQP node, properties + application-properties + body |
| MQTT | AuditMqttIntegrationTests |
publish to a topic with QoS 1+, retained flag (audit stream for IoT gateways) |
| Elasticsearch | AuditElasticsearchIntegrationTests |
bulk indexing into identity-events-*, ready for Kibana |
| Redis | AuditRedisIntegrationTests |
publish to pub/sub (live firehose) + optional append to a Stream (replayable history) |
Plus AuditEventSinkProcessorTests (unit tests on the fan-out processor) and AuditCompletenessFullCycleTests in FullStack/ — a full-cycle run that performs N mutating actions over HTTP and then checks that every one landed in the configured sinks. So "audit goes anywhere" means CI runs 8 real sinks on every commit (Kafka, RabbitMQ, IBM MQ, AMQP, MQTT, Elasticsearch, Redis, SQL) and verifies events serialize and arrive. Not an architecture slide — a green build against real infrastructure.
Real-world fan-out
From(IdentityEndpoints.Events)
.Marshal(...)
.Multicast(
To("postgres://?table=identity_audit"), // durable retention
To("kafka://broker:9092/identity-events?acks=all"), // real-time SIEM stream
To("elasticsearch://logs/identity-events?bulk=true"), // Kibana ops dashboard
To("wmq://QM.PROD?queue=COMPLIANCE.IDENTITY.AUDIT"), // a bank's compliance core
To("file:///var/log/identity/audit-${date:yyyy-MM-dd}.jsonl?append=true") // locked-down fallback
);
One identity server, one event source (direct-vm://identity-events), five parallel sinks of different natures. No identity server on the market does this out of the box — it's either "pick one sink in config" or "a webhook to your HTTP endpoint, do the fan-out yourself." This is transport-agnosticism at the event level: IBM MQ for banks that can't leave WMQ on compliance grounds, RabbitMQ for general-purpose, Kafka for streaming analytics — Identity uses the same transport set as any other business route.
Observability
Metrics ride the standard OpenTelemetry meter RedbIdentity — plug it into any OTel pipeline:
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter("RedbIdentity"));
Login/failure counters (tagged reason), MFA verifications (method, result), tokens issued (grant_type, token_type), token errors, rate-limit rejections, and a password-verify duration histogram that catches CPU regressions on hashing. Plus a dedicated security log channel (RedbIdentity.Security) so a SIEM can subscribe to audit-grade events without sifting routine logs, and module health probes (db, signing-keys, data-protection) under Tsak's aggregated /api/health/{startup,live,ready}.
Logical-level backup via redb.Export — no pg_dump, no vendor lock
The section that usually gets left for later and then suddenly becomes a problem. Most identity servers have one of two backup stories: pg_dump/mssqldump (a binary snapshot — you can't restore a subset, it's pinned to the DB version, and Postgres → MSSQL migration is off the table) or an application-level export through an admin API (usually shallow, no FK order, doesn't cover custom schemes).
There's a separate package — redb.Export — that does a logical-level dump into a portable .redb format:
- JSONL stream (newline-delimited JSON), optionally zipped.
- Foreign-key-safe order — types → roles → users → user_roles → lists → list_items → schemes → structures → objects → permissions → values. Single-pass restore.
-
Scheme-subset filter —
--schemes UserProps,ApplicationProps. You can restore a subset too. - Per-type counts — the header knows exactly how many rows of each type were exported; the footer asserts it.
-
Multi-provider — an
IDataProviderfor Postgres / MSSQL / SQLite. One.redbfile is portable across engines; dialect differences are encapsulated in the provider. - dryRun — count what would be exported without writing to disk.
A production example — a daily cron backup through redb.Route
From a real project. TsumBackupRouteBuilder.cs:
public class TsumBackupRouteBuilder : RouteBuilder
{
protected override void Configure()
{
var backupConfig = Context?.GetProperty<IDictionary<string, object?>>("Backup");
var directory = backupConfig?.TryGetValue("Directory", out var dir) == true
? dir?.ToString() ?? "backups" : "backups";
var retentionDays = backupConfig?.TryGetValue("RetentionDays", out var ret) == true
&& int.TryParse(ret?.ToString(), out var rd) ? rd : 7;
Context!.SetProperty("_backup.directory", directory);
Context.SetProperty("_backup.retentionDays", retentionDays);
From("cron://tsum-backup?schedule=0 0 3 * * ?") // ← Quartz cron, daily at 03:00
.RouteId("tsum-backup-cron")
.ProcessWithRedb(RunBackupAsync); // ← a named-scope IRedbService is injected
}
private async Task RunBackupAsync(IRedbService redb, IExchange exchange, CancellationToken ct)
{
var pgConn = redb.Configuration.ConnectionString;
var directory = Context!.GetProperty<string>("_backup.directory")!;
var retentionDays = Context.GetProperty<int>("_backup.retentionDays");
Directory.CreateDirectory(directory);
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HHmmss");
var filePath = Path.Combine(directory, $"tsum_backup_{timestamp}.redb");
var provider = ProviderFactory.Create("postgres");
await provider.OpenAsync(pgConn, ct);
var exportService = new ExportService(provider, verbose: false, batchSize: 10000);
await exportService.ExportAsync(filePath, schemeIds: null, compress: true, dryRun: false, ct);
await provider.DisposeAsync();
RotateBackups(directory, retentionDays);
}
private void RotateBackups(string directory, int retentionDays)
{
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
foreach (var file in Directory.GetFiles(directory, "tsum_backup_*.redb")
.Select(f => new FileInfo(f))
.Where(f => f.CreationTimeUtc < cutoff))
{
file.Delete();
}
}
}
No separate backup service, no Bash cron job, no separate deployment: one From("cron://...") (Quartz is already built into the route system), .ProcessWithRedb(...) injects a named-scope IRedbService, ExportService.ExportAsync(..., compress: true, ...) turns on compression by argument, and RotateBackups is a plain date-based retention sweep. The file drops into the project's domain with a single context.AddRoutes(new TsumBackupRouteBuilder()). Identity gets the same backup for free — because Identity's data lives in the same REDB.
Restore and cross-provider migration
var importService = new ImportService(provider, verbose: false, batchSize: 10000);
await importService.ImportAsync("tsum_backup_2026-06-20_030000.redb",
schemeIds: null, // null = whole file; a subset also works
truncateBefore: false, // or true for a clean restore
ct);
One of the killer moves is migrating the whole identity stack between engines without touching code, in three CLI commands:
# 1. Export from the old DB
redb export -p postgres -c "Host=src;Database=redb;..." -o data.redb --compress -v
# 2. Bootstrap the schema on the new DB
redb init -p mssql -c "Server=dst;Database=redb;..." -v
# 3. Import
redb import -p mssql -c "Server=dst;Database=redb;..." -i data.redb --clean -v
The .redb format is typed and cross-provider; dialect differences are encapsulated in IDataProvider. After the migration Identity sees the exact same data — the same OpenIddictApplication ids, the same user subs, the same signing keys. That's a level of portability IdentityServer (bound to its EF provider and migrations), Keycloak (Java SPI ↔ Postgres/MariaDB), and Auth0 (no on-prem at all) don't offer.
redb.CLI — a global .NET tool for the BAU operations
The same pipeline is on the command line via redb.CLI (on nuget.org):
dotnet tool install --global redb.CLI
Four commands cover the identity operational cycle:
-
redb init— bootstrap the REDB tables/sequences/functions/views in a clean DB (-p postgres|mssql|sqlite). The one-time analog of "migrations init"; after that the schema lives through the scheme registry. -
redb schema— emit the SQL script for DBA review (redb schema -p postgres -o redb_schema.sql, or pipe intopsql). Handy for change management (a PR flow), a CI/CD DDL artifact, or a compliance review without standing up a DB. -
redb export— backup to.redb(--compress,--schemes 100,200,300,--dry-run). -
redb import— restore from.redb(--cleanto truncate first,--dry-runto check compatibility).
For identity that gives you: compliance retention (encrypt the .redb at the FS/KMS level, retention for N years), DR testing (restore into staging for a regular drill without pg_restore choreography), a forensic snapshot at incident time (one cron trigger over direct-vm:// and you have it), a scheme-subset export for a regulator, and cross-version migration. The kind of thing cloud IDPs charge for as an Enterprise add-on; here it's first-class.
Host-agnostic — Tsak is an option, not a dependency
A common question: "you keep mentioning hot-reload, a cluster, a dashboard — is all of that mandatory?" No. The identity server is a set of redb.Route routes; what hosts them is up to you.
Option 1 — a plain .NET worker. Your own Program.cs on Host.CreateApplicationBuilder:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddRedbCore(builder.Configuration);
builder.Services.AddRedbRoute();
builder.Services.AddRedbIdentity(builder.Configuration);
var host = builder.Build();
var ctx = host.Services.GetRequiredService<IRouteContext>();
ctx.AddRoutes(new IdentityCoreRouteBuilder(/*...*/));
ctx.AddRoutes(new HttpFacadeRouteBuilder(/*...*/));
await ctx.Start();
await host.RunAsync();
dotnet run and the OIDC server is up. No runtime container, no dashboard, no .tpkg. Deploy it as a plain Worker Service / systemd unit / Windows service / Docker container.
Option 2 — inside an ASP.NET Core monolith. Identity embeds in the same process and reuses the host's IServiceProvider (DataProtection key ring, IConfiguration, ILogger) — no second initialization.
Option 3 — a service-mesh sidecar. Identity as an in-process compliance capsule inside each microservice; service A mints tenant-local tokens over direct-vm://identity-token with no network hop. No Tsak — services.AddRedbIdentity(...) is enough.
Option 4 — redb.Tsak for an enterprise control plane. When Identity lives in Tsak you get a management layer you'd otherwise hand-write:
| What Tsak adds | For Identity |
|---|---|
Hot-reload of one .tpkg without dropping in-flight messages in the other contexts |
Update redb.Identity.Core.Module.tpkg (a new redirect_uri check) — Kafka consumers / the audit pipeline / federation callbacks keep running |
| Multiple named contexts in one process | Identity next to your orders / payments / analytics — each isolated via its own AssemblyLoadContext
|
| Blazor Server dashboard + REST API | An operator sees every identity route, per-route metrics, ring-buffer logs, and can stop one route (say /connect/register under attack) without touching the rest |
A CLI (tsak route stop, tsak context stop orders, …) |
CI/CD without a custom kubectl wrapper |
REST endpoints (/api/contexts, /api/modules, /api/routes, /api/cluster, /api/logs, /api/watchdog, /api/scheduler) |
Your own ops portal via the typed ITsakApiClient
|
| Cluster mode with leader election + context redistribution | A replica dies — Tsak redirects its contexts to healthy nodes |
A Quartz IScheduler in every context |
Token cleanup, session expiry, JWKS auto-rotation, audit retention on a standard cron |
| A watchdog that spots hung routes | A federation callback stuck at 30s gets restarted — no manual ops page at 3 a.m. |
Prometheus /metrics + OTLP/Jaeger traces
|
Per-token-issue p50/p95/p99 + per-route error rate in Grafana, traces in Jaeger |
| API key + HMAC-SHA256 + roles + expiry + revocation for the admin API | Identity admin endpoints (e.g. /signing-keys/rotate) sit behind the same auth layer as route management |
| Five-layer config hot-reload | Change Identity.RateLimit.PerIpPerMinute in context.json — picked up, no restart |
Tsak earns its keep when you have dozens of independent pipelines and Identity is one of them; when an ops team wants a browser dashboard over kubectl exec; when Identity runs on 3+ nodes and you want automatic redistribution; when you need hot-reload without downtime windows; or when you want to drop in your own extensions (IModuleProvider, IRouteLifecycleListener, a watchdog strategy — plain interfaces in redb.Tsak.Core). It's overkill when it's one server you can restart, an ASP.NET monolith with Identity inside, or a single sidecar microservice.
The identity stack doesn't know Tsak exists — there's not one using redb.Tsak in redb.Identity.Core. The deployment spectrum:
┌── Option A ── A 30-line Program.cs (junior-friendly) ─────────────────────┐
│ Worker Service + AddRedbIdentity + AddRoutes. Deploy via systemd / Docker.│
├── Option B ── An ASP.NET Core monolith with Identity inside ──────────────┤
│ One process, Identity next to your controllers. │
├── Option C ── A sidecar in each microservice ─────────────────────────────┤
│ In-process embedding — direct-vm token issue, no network hop. │
├── Option D ── Tsak with a single identity module ─────────────────────────┤
│ Hot-reload, dashboard, REST/CLI. No cluster mode, no dozens of modules. │
├── Option E ── Tsak full enterprise: Identity + your 20 other modules ─────┤
│ Cluster + leader election + Prometheus + Jaeger + Grafana + k8s. │
│ Your own custom Tsak extensions (IModuleProvider, watchdog, ...). │
└────────────────────────────────────────────────────────────────────────────┘
⬇️ Same identity server at every level ⬇️
Extending alongside Identity — your own scheme, your own routes
The identity server isn't a closed box — it lives in the same REDB and the same IRouteContext as any business route of yours. So you declare a typed props class, register the scheme next to the identity schemes (no ALTER TABLE), get full CRUD + LINQ through the same IRedbService, and WireTap your routes onto identity-events:
// Your module next to Identity — no permission to ask for
public sealed class OrgEnrollmentProps
{
public long UserId { get; set; } // FK to an identity user via _users._id
public string OrganisationCode { get; set; } = "";
public DateTimeOffset EnrolledAt { get; set; }
public string Role { get; set; } = "";
public Dictionary<string, string>? Tags { get; set; }
}
services.AddRedbScheme<OrgEnrollmentProps>();
var enrollments = await redb.Query<OrgEnrollmentProps>()
.Where(e => e.OrganisationCode == "ACME")
.WhereRedb(o => o.Key == userId)
.ToListAsync();
From(IdentityEndpoints.Events)
.Filter(e => e.GetHeader<string>("event-type") == "UserCreated")
.Process((e, ct) => /* your onboarding flow */)
.To("rabbitmq://org-enrollment-pipeline");
No extension points, no SPI, no plugin manifests, no rebuild of the identity server — it's ordinary C# in an ordinary assembly that co-exists with the identity stack in one process and one database. Drop it into Tsak as its own .tpkg (deploys independently) or compile it into the same worker.
An addon calls the identity server from the inside over direct-vm — no HTTP, one transaction
This is stronger than a WireTap. The identity routes are published as direct-vm://identity-token, direct-vm://identity-authorize, direct-vm://identity-users-delete, … — all the standard endpoint URIs (IdentityEndpoints.Token, .Authorize, …, the full list in IdentityEndpoints.cs). So an addon calls Identity like its own library — via message passing, but inside one process with no network hop.
Example: the addon enrolls a user in an organization and mints their onboarding token through the identity server, all in one transaction:
public sealed class OrgEnrollmentRoutes : RouteBuilder
{
protected override void Configure()
{
WithRedbTx(From("direct-vm://org-enrollment-create-with-token"))
.ProcessWithRedb(async (redb, e, ct) =>
{
// 1. Create the enrollment record in OUR own scheme
var enrollment = new RedbObject<OrgEnrollmentProps>(new OrgEnrollmentProps
{
UserId = e.GetHeader<long>("user_id"),
OrganisationCode = "ACME",
EnrolledAt = DateTimeOffset.UtcNow,
Role = "member",
});
await redb.SaveAsync(enrollment, ct);
})
// 2. ↓↓↓ CALL THE IDENTITY SERVER over direct-vm — NO HTTP ↓↓↓
.Process((e, ct) =>
{
e.In.Headers["grant_type"] = "client_credentials";
e.In.Headers["client_id"] = "org-onboarding-bot";
e.In.Headers["scope"] = "openid org:onboarding";
return Task.CompletedTask;
})
.To(IdentityEndpoints.Token) // ← "direct-vm://identity-token" — Identity in the same process
// ↑↑↑ access_token is already in e.Out.Body, no HTTP round-trip happened ↑↑↑
.Process(async (e, ct) =>
{
var tokenResponse = e.Out!.Body as Dictionary<string, object?>;
var accessToken = tokenResponse!["access_token"]?.ToString();
await _emailSender.SendOnboardingAsync(/* userId */, accessToken!, ct);
});
}
}
What happened: one route does the enrollment in its own scheme + a token issue in the identity stack + an email notification, in sequence, in a single exchange chain; WithRedbTx(...) wraps all of it — both the save into OrgEnrollmentProps and Identity's token issue (which also writes to _objects via RedbTokenStore) — one redb tx, one atomic commit; no HttpClient, no JSON serialization, no TLS handshake; and the same correlation id flows through, so Identity logs the token issue under the same trace id as the enrollment. Any direct-vm endpoint is available to the addon as an in-process call — not "SDK wrappers," the endpoints themselves.
For contrast: to mint a token from Java next to Keycloak you have to write a wrapper (HttpClient → form-data → HTTP → JSON back → retry/circuit-breaker → SDK classes → keep the SDK in sync with the Keycloak version). Every wrapper is a failure point, a serialization point, a separate-transaction point (atomic semantics between "minted a token" and "wrote to my DB" are impossible), a version-drift point, and a separate-test point. Here it's one line, .To(IdentityEndpoints.Token), and an atomic commit via WithRedbTx. That gap is exactly why people write their own identity servers in the first place: the boxed ones won't let you use identity as a library next to your business logic.
Your own facade on your own protocol
The HTTP facade is just one connector over the direct-vm core. Nothing stops you from putting your facade in front of a corporate/proprietary protocol. The core doesn't care — it receives messages over direct-vm://, and how they got there is your business:
| Scenario | Solution |
|---|---|
| Corporate binary RPC (custom framing over TCP, cert pinning) |
From("custom-rpc://0.0.0.0:9999") → parse the frame → To(IdentityEndpoints.Token)
|
| A gRPC facade |
From("grpc://.../oidc.IdentityService/IssueToken") → map to headers → To(IdentityEndpoints.Token)
|
| MQTT for IoT (devices that can't do HTTP/2) |
From("mqtt://broker:1883/device/+/token-request") → forward into the device_code grant |
| An internal enterprise bus (WMQ as the only transport in a bank's perimeter) |
From("wmq://QM.PROD?queue=IDENTITY.TOKEN.REQ") → forward → reply on IDENTITY.TOKEN.RESP
|
| A crypto wrapper (token requests in a custom envelope) |
.Process(DecryptEnvelope) → To(IdentityEndpoints.Token) → .Process(EncryptEnvelope)
|
Any redb.Route connector can front the identity server. Want tokens minted from a RabbitMQ queue? One RouteBuilder. A Slack bot as the identity admin API? One RouteBuilder.
A separate control facade for admin endpoints on a public perimeter
A scenario people rarely spell out: the identity server has to sit on a public IP (cloud edge, partner API, multi-region). You can't blanket it in a VPN/mTLS — real RP clients come in over HTTPS from browsers and mobile apps, an external federation IdP calls back to your /connect/federation/callback, and back-channel logout needs a publicly resolvable URL. So the standard OIDC endpoints (/connect/*) have to be public.
But the critical admin endpoints — /signing-keys/rotate, /users/{id}/force-revoke, /internal/bootstrap-admin, application mutations — don't have to be HTTP. You can expose them through a separate facade over your own proprietary TCP framing with custom magic bytes, a protocol version, non-standard markers — on a separate port, on a separate network:
// A control facade over a proprietary protocol — separate port, separate network
From("custom-tcp://0.0.0.0:7891")
.RouteId("identity-admin-proprietary-facade")
.Process(ValidateMagicBytes) // ← a client that doesn't know the format never gets in
.Process(ParseCustomFraming) // ← binary format with CRC + version + opcode
.Process(VerifyProprietaryAuthHmac)// ← your own HMAC scheme, not an RFC
.Choice()
.When(e => e.GetHeader<int>("opcode") == 0x42).To(IdentityEndpoints.SigningKeyRotate)
.When(e => e.GetHeader<int>("opcode") == 0x43).To(IdentityEndpoints.UserForceRevoke)
.When(e => e.GetHeader<int>("opcode") == 0x44).To(IdentityEndpoints.BootstrapAdmin)
.EndChoice()
.Process(SerializeCustomResponse);
This is not a substitute for crypto — auth + HMAC + a role check are on every opcode. It's an extra layer of defense-in-depth that works not on crypto but on reduced attack surface and information asymmetry: off-the-shelf toolkits (Burp, sqlmap, msf) and botnet scanners don't understand custom framing and don't scan custom TCP protocols at all; an attacker has to reverse-engineer the format before they can even form a syntactically valid request. Every day of that delay is a day for your security team to detect and respond. It's a pattern banks, defense, and the public sector have long used (their own RDBMS protocols, their own serialization on service-to-service channels); it used to require rewriting the stack around a proprietary protocol, and now it's one RouteBuilder in front of a standard OIDC core. Standard RPs keep talking /connect/* over HTTPS — nothing changes for them.
Extension points — everywhere, not just Identity
The freedom to extend is a property of the whole ecosystem:
| Layer | Extension points |
|---|---|
| REDB storage |
IRedbObjectStorageProvider (a different backend under _objects), ISqlDialect, IPasswordHasher, IPropsSaveStrategy
|
| redb.Route | your own connector (From("myprotocol://...")), IProcessor, a custom EIP, IDataFormatRegistry, IExpression
|
| redb.Identity |
IExternalUserProvider, IPasswordHasher, IPasswordBreachChecker, IRateLimitStore, IEmailNotificationChannel, IBackgroundDeletionService, IClaimMapper — plus any custom RouteBuilder as a peer route |
| redb.Tsak |
IModuleProvider, IRouteLifecycleListener, IWatchdogStrategy, ITsakAuthProvider
|
| redb.Route.Controllers |
IControllerDispatcher, IActionResultMapper
|
Same shape everywhere: a public interface with a clear contract + DI registration + your implementation. Register yours and Identity picks it up without noticing the swap. No SPI manifests, no vendor lock-in moments.
Combinatorics with the LLM connector
Put an LLM connector next to Identity (redb.Route.Llm — Anthropic / OpenAI / Azure OpenAI), a Kafka stream of identity events, and your own domain schemes. Passwords stay Argon2id-hashed in the DB the whole time — the LLM never sees plaintext (the prompt gets an IdentityEvent: event-type, user-id, client-id, ip-address, timestamps; sensitive material is filtered in EventDispatchProcessor before the WireTap). What you get is another .To() / .Filter() / .Process() in your RouteBuilder, not a separate product behind an extra license:
| Scenario | Pipeline |
|---|---|
| AI anomaly detector on login patterns |
From(IdentityEvents).Filter(type=="LoginSuccess").To("llm://anthropic?prompt=...") → risk score → direct-vm://identity-sessions-revoke past a threshold |
| Real-time fraud scoring on token issue |
From(IdentityEndpoints.Token).WireTap(To("llm://...")) → score > threshold → revoke + flag-for-review |
| Conversational admin via Telegram/Slack |
From("telegram://admin-bot") → an LLM agent with .AsLlmTool() wrappers over admin endpoints → "revoke all sessions for foo@acme.com" in one message |
| LLM classification of audit events |
From(IdentityEvents).To("llm://claude?prompt=Classify severity...") → To("kafka://security-incidents-prioritised")
|
| Auto compliance reports per quarter |
From("cron://quarterly-report") → To("postgres://identity_audit?query=...") → LLM aggregation → .To("email://compliance@org.com")
|
| LLM validation of a redirect_uri at DCR | a new OIDC client → .To("llm://...?prompt=Is this redirect_uri benign?") → human-in-the-loop for the suspicious ones |
You already have Identity with direct-vm endpoints, the LLM connector with .AsLlmTool(), Kafka/RabbitMQ for streaming, Tsak for deployment, and your own RouteBuilders — wiring them into pipelines is a couple of hours, not a quarter-long epic.
Enterprise scale — thousands of microservices, hundreds of thousands of users
Those features (a transport-agnostic core, schemes as data, cluster-safe primitives, live JWKS refresh) exist because a modern enterprise looks like this: thousands of microservices talking client_credentials and token-exchange delegation chains; hundreds of thousands of live sessions (mobile + web + thick clients) with concurrent federation round-trips; several datacenters running identity replicas behind an LB; and regulatory rules that forbid identity data from leaving the perimeter.
Service-to-service auth — without the round-trip
The classic shape: service-A → POST /connect/token → identity (HTTP), then service-B → POST /connect/introspect → identity (HTTP). That's two network hops to Identity on every internal call — at 10K internal RPS the identity server becomes a bottleneck, a single point of failure, and p99 tax. Here:
-
Embedding mode.
redb.Identity.Corelinks into each microservice that has to mint/validate tokens;client_credentialsissue goes overdirect-vm://identity-token— zero network hop. -
JWT validation without introspect. Resource servers validate the bearer locally via
redb.Identity.Resource.Dpop(or the vanillaJwtSecurityTokenHandlerfor non-DPoP cases). The JWKS is fetched throughIConfigurationManager<OpenIdConnectConfiguration>with a long TTL — a network call once an hour, not per bearer. -
Token exchange (RFC 8693) for delegation chains. Service A takes the user's token and calls service B on their behalf; service B sees the
actclaim chain (RFC 8693 §4.1) with the delegation history.demo_token_exchange.ps1covers it.
Cluster stability — for many-thousand sessions
Identity is built for N replicas where leadership rotates with no external coordination:
| Concern | Mechanism |
|---|---|
| Stateless replicas | all handlers are Scoped, no static state — any replica serves any request |
| DataProtection key ring across replicas |
RedbXmlRepository — keys in redb; each replica refreshes its snapshot every XmlRepositoryRefreshInterval (60s). Not cluster-gated — every node must catch keys others rotated |
| OAuth signing keys across replicas | redb-backed RSA 2048 PEMs, DataProtection-encrypted, bootstrapped under a distributed lock (one replica mints the first key) |
| Federation nonce / rate-limit counters | a Redis backend, cluster-wide (RFC 6749 §10.12 / RFC 6585 §4) |
| Schema init | a cluster-wide lock via LockForUpdate — leader initializes, followers wait |
| Background trash purge |
TryClaimOrphanedTaskAsync — any replica picks up an orphan after another crashes |
| Signing key rotation |
IOptionsMonitorCache.TryRemove invalidates the cache on every replica |
| Back-channel logout across replicas |
/revoked-sids/add writes the revocation; /revoked-sids/since?cursor= lets each RP replica pull deltas. Push-and-poll survives lost nodes and network splits |
No ZooKeeper, no separate store for keys — it's all redb objects. In a three-node cluster around ~5K /token RPS the stack runs at ~50–80ms p99 (the Pro path with PVT indexes on value_string for the client_id lookup).
Data sovereignty — identity stays inside the perimeter
Self-hosted means the identity data physically lives on your hardware. No callback to a third-party processor, no shipping emails/phones to a cloud vendor. That matters for banks (KYC on a regulated network), health/medical (HIPAA / GDPR Article 9), the public sector (data residency), and B2B SaaS on-prem (the customer deploys into their own VPC). redb.Identity doesn't phone home — no telemetry, no pings, no update checks. One Postgres + one .NET worker + your network.
Multi-tenant through schemes
Classic OIDC multi-tenancy — /{tenant}/connect/token, separate DB schemas, separate key rings — needs tenant routing in every handler, tenant-aware migrations, and per-tenant key rotation. Here isolation is possible at the REDB scheme level: the split is physical, through _id_scheme, and a query from tenant A can't touch tenant B's data even through a bad filter. Today it's a capability, not a packaged feature: production deployments usually start single-tenant and layer multi-tenancy on via a tenantId claim — we'll prioritize it on inbound demand.
Live demos: 61 probes against a real server
Beyond the xUnit suite, demos/ holds 61 self-contained PowerShell probes that drive a live server over its real HTTP surface — one demo_*.ps1 per protocol contract. Each sets up its own client via DCR, runs the flow end-to-end, and asserts the wire-level result (status codes, headers, token/JWKS shape, replay rejection). Executable RFC docs and a black-box regression net in one.
cd redb.Identity/demos
pwsh -File .\run_all.ps1 # every demo_*.ps1 in canonical order
pwsh -File .\run_all.ps1 -Only mfa # only the MFA ones
pwsh -File .\run_all.ps1 -StopOnFail # bail on the first failure
run_all.ps1 runs each probe in its own pwsh child process (a hard failure in one can't take the rest down), streams output, captures a transcript to demos/_logs/, and prints a pass/fail table. The map by category:
Grant types demo_client_credentials / demo_authcode_pkce / demo_refresh_rotation
demo_device_code (+_ci) / demo_password_ropc / demo_token_exchange
Endpoints demo_discovery_jwks / demo_discovery_shape / demo_introspect_revoke
demo_userinfo / demo_jwks_rotation
Authorize surface demo_auth_extras (RFC 9207 iss, form_post) / demo_prompt_max_age
demo_acr_values / demo_claim_probes
PAR / DPoP demo_par / demo_par_per_client / demo_dpop
Logout demo_logout_endsession / demo_backchannel_logout
DCR demo_dcr_lifecycle / demo_private_key_jwt
SCIM 2.0 demo_scim / demo_scim_bulk / demo_scim_etag
MFA demo_mfa_totp / demo_mfa_recovery_codes / demo_mfa_disable_replace
demo_password_change_negatives
Self-service demo_account_register_verify / demo_me_profile / demo_me_email_change
demo_me_sessions / demo_me_delete / demo_password_reset
Federation demo_federation / demo_federation_e2e / demo_federation_github
demo_federation_link_unlink
Admin demo_admin_scopes / demo_groups_roles_claims / demo_sessions_admin
demo_throttle_rfc6585 / demo_jwt
The only probe that needs a human is device-code consent — there's a non-interactive demo_device_code_ci.ps1 for CI.
Reference BFF + Blazor admin, included
redb.Identity.Web is a reference Blazor Server app that demonstrates the HARDLINE BFF pattern:
- references only
redb.Identity.Contracts+redb.Identity.Client— noCore, noHttp; - every Identity call goes through the typed
IIdentityClient— never a rawHttpClient; - OIDC Authorization Code + PKCE,
SaveTokens=true, back-channel-logout sink + poll fallback for multi-replica RPs.
Pages cover both self-service (/Me/*: profile, password, MFA/TOTP, WebAuthn credentials, sessions, federated links, consents) and admin (/Admin/*: users, groups-as-tree, applications, scopes, claim mappers, roles, federation providers, sessions, tokens, audit with a JsonViewer, SCIM browser, settings). It ships as source in the repo, not a package — clone it and run it, or lift it as a starting point.
And a typed SDK for anyone calling Identity from the outside:
services.AddIdentityClient(o =>
{
o.BaseUrl = "https://identity.local";
o.AccessTokenProvider = new ClientCredentialsAccessTokenProvider(
clientId: "my-svc", clientSecret: cfg["IdentitySvcSecret"]!, scopes: ["identity:manage"]);
});
public class UserSync(IIdentityClient identity)
{
public async Task RunAsync(CancellationToken ct)
{
var page = await identity.Users.ListAsync(new UsersListRequest { Limit = 100 }, ct);
foreach (var u in page.Items) await PersistAsync(u, ct);
// back-channel revocations — pull deltas since the last cursor
var since = await identity.RevokedSids.GetSinceAsync(_cursor, ct);
foreach (var e in since.Entries) _cache.Apply(e);
_cursor = since.NextCursor;
}
}
Every HTTP error is normalized to RFC 7807 ProblemDetails and surfaces as ApiException.
A little honest comparison
| ASP.NET-bound IS (Duende, OpenIddict samples) | redb.Identity | Standalone IAM (Keycloak / Auth0) | |
|---|---|---|---|
Call token from another in-process module |
Loopback HTTP |
To("direct-vm://identity-token") — same exchange, zero-copy |
Network hop |
| Add a transport (gRPC, MQ, SignalR) | Rewrite endpoints as gRPC services / consumers | Drop a facade .tpkg, point it at direct-vm://
|
Vendor adapter (if it exists) |
| Replace HTTP with RabbitMQ entirely | Major refactor | Drop the HTTP facade. Keep Core. | Not possible |
| Storage | Custom EF schema + migrations | redb RedbObject<TProps> — code-first, no migrations |
Vendor schema, vendor migrations |
| Custom claims |
jsonb blob + your own indexes |
Dictionary on props — natively queryable |
Vendor attribute model |
| Multi-replica key sharing | DIY DataProtection + JWKS | Built-in: redb key-ring + signing key store | Built-in (vendor-specific) |
| Embed in your worker as a library | No | Yes — one or two .tpkg, or call Core directly |
No |
| DB providers | One per build | PG / MSSQL / SQLite from one codebase | Vendor |
| An addon as a peer in the same DB/transaction | Controllers alongside, but storage is separate (EF) | Own scheme + direct-vm calls + one redb tx | Only from outside, over REST |
| License | Mixed (Duende: commercial) | Apache 2.0 | Mixed |
Two honest caveats so this doesn't read as a hit piece. Keycloak/Auth0 are finished products with admin UX, multi-tenancy, and an ecosystem we don't reproduce one-for-one; if you want "install the box and stop thinking," they're still a valid pick. Duende IdentityServer is the most mature .NET stack out there with a huge community — the argument isn't "it's bad," it's "it's architecturally married to the HTTP pipeline, and that isn't always what you want." On social providers out of the box (Sign in with Apple, LinkedIn, X) Auth0/Okta are ahead — we ship generic OIDC + GitHub OAuth2, and the rest you write as federation providers (there's a mock provider in demo_federation_e2e.ps1 showing how). That's an honest limit, not a blocker. redb.Identity wins precisely where identity should be a built-in part of your .NET system rather than a service next door, and where you'd rather not drag along migrations and a second storage model.
What isn't done yet (and that's fine)
This list is honest because it's checkable: everything absent from it, our own /.well-known/openid-configuration will confirm or refute in a single curl.
Not implemented: RFC 8705 (mTLS + cert-bound tokens — tls_client_certificate_bound_access_tokens: false), 9101 (JAR — request objects aren't consumed; request= gets request_not_supported), 8707 (Resource Indicators), 9396 (RAR), 9470 (step-up), FAPI 2.0, CIBA, OIDC Federation 1.0. From OIDC Core: no pairwise sub (public only) and, per OAuth 2.1, no implicit and no hybrid flow.
Two things we deliberately chose not to build — and the reasoning matters more than the checkbox:
Front-Channel Logout 1.0. It signs RPs out through an iframe to each client, which means it rides on third-party cookies. Safari's ITP blocks them; Chrome is burying them. The mechanism is broken by design in a modern browser, and anyone ticking that box owes you a footnote saying "works sometimes." We ship Back-Channel Logout instead — server to server, signed logout token, plus a pull feed of revoked sids for multi-replica RPs. It is strictly better and doesn't depend on cookies at all. We'd rather have the mechanism that works than the checkbox.
HOTP (RFC 4226) as a standalone method. Counter-based OTP is effectively unused in 2026; the world runs on TOTP. RFC 4226 lives on as the foundation of TOTP — and in that role we do implement it (160-bit secret per §4). Building a separate counter with a resync window purely to add a row to a table is work for the table, not for the user.
Planned facades (same .tpkg pattern, no Core changes): redb.Identity.Grpc, .Rmq/.Amqp/.IbmMq, .SignalR, .Kafka (event-only sink). PRs welcome.
Quick start
Fastest path — Docker (SQLite + HTTPS, out of the box). The image is a working OpenID provider with zero configuration:
docker run -d -p 5002:5002 ghcr.io/redbase-app/redb-identity-backend:latest
curl -k https://localhost:5002/.well-known/openid-configuration
It bundles a self-signed dev cert (CN=localhost) — call it with curl -k for a local trial. Variants: backend (OIDC only), managed (+ Tsak dashboard), full (+ BFF login/admin on :8087). Compose stacks and DOCKER.md live in publish/docker/. Images are cosign-signed.
Not a Docker fan? The releases page has a self-contained archive (Worker + modules, also SQLite + HTTPS out of the box, cosign-signed): unpack and run start-full.ps1 / start-full.sh.
For embedding in your own project — packages are on nuget.org. Take what you need — the host picks the provider:
# Core OIDC / OAuth 2.1 engine (OpenIddict on redb.Route) + a storage provider
dotnet add package redb.Identity.Core
dotnet add package redb.Postgres.Pro # or redb.MSSql.Pro / redb.SQLite.Pro
# HTTP facade (OIDC + management + SCIM)
dotnet add package redb.Identity.Http
# Typed client SDK (IIdentityClient) — for BFFs / services
dotnet add package redb.Identity.Client
Or build the .tpkg modules from source and drop them into a Tsak worker:
cd redb.Identity
.\scripts\pack-tpkg.ps1 # → redb.Identity.Core.Module.tpkg + redb.Identity.Http.tpkg
A full dev setup with real infrastructure:
# 0. (optional) redb.CLI globally — for DB work from the terminal
dotnet tool install --global redb.CLI
# 1. Dev compose (Postgres + GreenMail + mock IdP + Redis + LDAP)
docker compose -f redb.Identity/deploy/observability/docker-compose.yml up -d
# 1.1. Initialize the REDB schema
redb init -p postgres -c "Host=localhost;Database=redb;Username=postgres;Password=postgres" -v
# 2. Run the worker with the tpkg modules
cd redb.Tsak/src/redb.Tsak.Worker
dotnet run
# 3. In another window — run a demo
cd redb.Identity/demos
pwsh demo_discovery_jwks.ps1 # OIDC discovery + JWKS + manual verification
pwsh demo_authcode_pkce.ps1 # full PKCE round-trip
pwsh demo_jwks_rotation.ps1 # runtime signing-key rotation
pwsh run_all.ps1 # every probe
Check a live server:
curl http://localhost:5000/.well-known/openid-configuration | jq
curl http://localhost:5000/.well-known/jwks | jq
And the one-shot first-admin bootstrap (self-seals via a sentinel flag; a second call returns 410 Gone):
curl -X POST http://localhost:5000/internal/bootstrap-admin \
-H "Content-Type: application/json" \
-d '{ "username": "admin", "password": "...", "email": "admin@local" }'
The full README — every endpoint table, the 79-event audit catalogue, the OpenTelemetry metrics map — is in the repo. Issues and feedback via GitHub Issues and Discussions.
Wrapping up
redb.Identity is the fourth option that was missing. Not an ASP.NET-bound engine where every endpoint is an HTTP middleware. Not a standalone IAM box with its own runtime and deploy. And not "roll your own" for the third time.
Three things you don't get elsewhere in one package:
-
Protocol ≠ transport. Every endpoint is a
direct-vm://route. HTTP is the first facade, but the next module callstokenwith no network, zero-copy. Want another transport — drop a.tpkg. Want HTTP gone — drop the facade, keep Core. -
No migrations. Users, applications, scopes are C# classes. Add a field and it's in prod the next instant. Custom claims are natively queryable, no
jsonb, no hand-rolled indexes. - Identity as part of your project. Take just the engine, zero facades, and call OAuth endpoints with a method call from inside your process — down to an addon project that mints a token in the same transaction as its own domain data. An identity server as a library, not a service next door.
Plus: PostgreSQL / MSSQL / SQLite from one codebase (1767 tests green on all three), RFC numbers cited right in the code, DPoP / PAR / DCR / SCIM / MFA / WebAuthn / back-channel logout, live JWKS rotation with no restart, a password-hashing pipeline with Argon2id + upgrade-on-login, audit "anywhere" proven by 8 integration tests against real brokers, logical-level backup + cross-provider migration, a cluster with no external coordination, host-agnostic deployment from a 30-line worker to a Tsak cluster, and a reference Blazor admin — all Apache 2.0.
If you try it, tell me in the comments which embedding shape fits you: service next door, in-process library, or a full server behind HTTP. Deeper dives are coming in the series — WebAuthn/passkeys as a Route processor, DPoP step by step, SCIM sync with AD, MFA without a god-class.
Sources and release: github.com/redbase-app/redb-identity. About the redb database: redbase.app. Past posts on the profile.
If this was useful — a ⭐ on GitHub helps others find it.












Top comments (0)