DEV Community

rinat kozin
rinat kozin

Posted on • Edited on

redb.Identity, the full tour: a transport-agnostic OAuth 2.1 / OpenID Connect server for .NET — protocol transport, and no migrations

series redb.Route

redb.Identity

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

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:

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.

redb.Identity

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 no Include. 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.Tsak

redb.Tsak

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

— 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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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_codecode → 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
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 engineredb.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 });
Enter fullscreen mode Exit fullscreen mode

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

redb.Tsak

redb.Tsak

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 threePassed: 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, not 401 invalid_token (§5.2);
  • Cache-Control: no-store on token / introspection / revocation (§5.1);
  • email_verified / phone_number_verified are JSON booleans, not strings (OIDC §5.1);
  • prompt=login / max_age route to /login and 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 / address scopes 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/userinfo didn't accept the access token in the POST body (RFC 6750 §2.2);
  • the profile claim 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'
Enter fullscreen mode Exit fullscreen mode

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 public sub is a GUID (stable, correct across instances); the hot key in the relational _users table 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.

redb.Identity


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:

  1. An admin hits POST /signing-keys/rotate (requires scope identity:applications.manage).
  2. PropsSigningKeyStore.RotateAsync persists the new key and marks the old one demoted — but it stays in the JWKS until retire, a grace window for in-flight tokens.
  3. IOptionsMonitorCache<OpenIddictServerOptions> and …ValidationOptions are invalidated immediately.
  4. The next OIDC handler re-evaluates the whole Configure → PostConfigure chain, and PropsSigningKeyStoreOpenIddictPostConfigure rebuilds SigningCredentials from the current store snapshot.
  5. 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 _hash field. UpdateAsync takes a row lock, checks the hash, and throws OpenIddictExceptions.ConcurrencyException on 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, IdentitySchemaInitListener takes LockForUpdate on a specific row — "leader under a cluster lock" — initializes the schemes, and releases. Followers see another node holds the lock, proceeding idempotently and wait.
  • Atomic claim for background tasks. Trash purge and orphaned-task recovery go through TryClaimOrphanedTaskAsync — an atomic UPDATE with a WHERE condition. Whichever worker flips pending → running first owns the work. No distributed locking needed.
  • Per-request cache invalidation via the DI scope. RedbApplicationStore._clientIdCache lives 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:

  1. SoftDeleteAsync synchronously re-parents the object under a trash scheme (scheme_id = -10). It instantly disappears from every Query<>(). The RP sees 204, the operation "succeeded."
  2. A BackgroundDeletionService polls the DB for pending trash containers every 5 seconds. Cluster-safe (TryClaimOrphanedTaskAsync); on a worker crash the next poll cycle picks it up.
  3. 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);
Enter fullscreen mode Exit fullscreen mode

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, ...);
    });
}
Enter fullscreen mode Exit fullscreen mode

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.");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    );
Enter fullscreen mode Exit fullscreen mode

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"));
Enter fullscreen mode Exit fullscreen mode

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 IDataProvider for Postgres / MSSQL / SQLite. One .redb file 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 into psql). 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 (--clean to truncate first, --dry-run to 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();
Enter fullscreen mode Exit fullscreen mode

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 ⬇️
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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);
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Embedding mode. redb.Identity.Core links into each microservice that has to mint/validate tokens; client_credentials issue goes over direct-vm://identity-token — zero network hop.
  2. JWT validation without introspect. Resource servers validate the bearer locally via redb.Identity.Resource.Dpop (or the vanilla JwtSecurityTokenHandler for non-DPoP cases). The JWKS is fetched through IConfigurationManager<OpenIdConnectConfiguration> with a long TTL — a network call once an hour, not per bearer.
  3. 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 act claim chain (RFC 8693 §4.1) with the delegation history. demo_token_exchange.ps1 covers 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
Enter fullscreen mode Exit fullscreen mode

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:

redb.Identity

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
Enter fullscreen mode Exit fullscreen mode

redb.Tsak

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 BFF

redb.Identity.Web is a reference Blazor Server app that demonstrates the HARDLINE BFF pattern:

  • references only redb.Identity.Contracts + redb.Identity.Client — no Core, no Http;
  • every Identity call goes through the typed IIdentityClientnever a raw HttpClient;
  • 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

redb.Identity github

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check a live server:

curl http://localhost:5000/.well-known/openid-configuration | jq
curl http://localhost:5000/.well-known/jwks | jq
Enter fullscreen mode Exit fullscreen mode

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" }'
Enter fullscreen mode Exit fullscreen mode

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:

  1. Protocol ≠ transport. Every endpoint is a direct-vm:// route. HTTP is the first facade, but the next module calls token with no network, zero-copy. Want another transport — drop a .tpkg. Want HTTP gone — drop the facade, keep Core.
  2. 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.
  3. 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)