All notable changes to redb.Identity will be documented in this file.
This changelog covers the planned NuGet/.tpkg packages that ship from this repository:
| Package | Description |
|---|---|
redb.Identity.Core |
OIDC / OAuth 2.1 engine: OpenIddict pipeline on redb.Route, redb-backed stores (Application, Authorization, Token, Scope, KeyRing, Session, Audit), direct-vm://identity-* API surface |
redb.Identity.Core.Module |
.tpkg host glue for redb.Identity.Core — IRouteModule entry point, configuration binding, named-redb wiring (identity) |
redb.Identity.Contracts |
Transport-agnostic request/response DTOs and route-name constants shared by Core, Http, and Client |
redb.Identity.Http |
HTTP / HTTPS facade .tpkg — OIDC discovery, authorize, token, userinfo, introspect, revoke, JWKS, PAR, DCR, SCIM, /me, management, browser flows |
redb.Identity.Web |
Server-rendered host pages: login, native consent, MFA enrollment, e-mail verification, password recovery, account self-service |
redb.Identity.Client |
In-process and HTTP client SDK for direct-vm://identity-* endpoints + backchannel OIDC client (BackchannelOidcClient) |
redb.Identity.DataProtection |
Standalone Microsoft.AspNetCore.DataProtection wiring on redb-backed key-ring storage (no ASP.NET) |
redb.Identity.Ldap |
LDAP / Active Directory federation provider — directory sync handler + bind-on-login adapter |
redb.Identity.Resource.Dpop |
Resource-server middleware: DPoP (RFC 9449) proof validation + replay cache for downstream APIs |
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Note on version history:
1.0.1is the first public source release (repo: redbase-app/redb-identity). The pre-1.0 history below is preserved as1.0.0-preview.*— the engine ran in internal Tsak deployments through the wholedirect-vm://-first design phase; those entries collapse that history. Versioning is unified across all packages in the table above — every release bumps every package together.From
3.4.0onward redb.Identity shares the ecosystem version (redbcore /redb.Route/redb.Tsak) instead of its own1.xline — see the[3.4.0]entry. The1.0.1–1.2.2tags stay as valid history; the jump to3.4.0is a realignment onto the shared number, not a breaking change. NuGet publication follows the source cut.
Ships with the ecosystem. The minor comes from
redb.Route; the change below is Identity's own, and the rebuild additionally carries the redb.Core tree-scope fix into every deployment.
RequireUniqueEmail was enforced by a check-then-insert: GetUsers(email) → Reject(409) →
CreateUser. Between the check and the insert two concurrent registrations could both see "no
match" and both write the same address, so RequireUniqueEmail did not actually hold under
concurrency (login was already atomic via its relational UNIQUE; e-mail was not).
UX_users_email— a partial unique index on_users(_email)(WHERE _email IS NOT NULL), created per-dialect byIdentityUniqueIndexesInitListeneralongside the existing_objectsindexes. This is the atomic DB guarantee — the check-then-insert is now only a fast, deterministic early error, not the enforcement. Partial (skips null e-mails, of which there are many) so it also works on SQL Server, whose plain UNIQUE allows only one NULL;_emailisNVARCHAR(450)there, so it indexes on all three dialects (unlike the_value_stringindexes that stay Postgres-only).- E-mail normalised to lower-invariant + trimmed before the check and the insert
(
AccountRegisterProcessor,BootstrapAdminProcessor). Without it a plain index on_emailwould treatA@x.comanda@x.comas distinct; normalising makes the stored value canonical so the index (andEmailExactlookups) enforce one identity. - Index violation is surfaced as
409 duplicate, not a500:AccountRegisterProcessorcatches aUX_users_emailunique-violation (message or inner-exception) — the same shape login already used for its "already taken" 409. The loser of a register race now gets a clean duplicate error.
AccountRegisterProcessor carried a per-exchange stopwatch with ~25 __arMark(...) calls (the
Console.WriteLine was commented out, so they were no-ops) and a VERIFY block that issued two
extra GetUsers queries after every CreateUser just to feed those no-op logs. Removed both —
two fewer DB round-trips per registration and no dead diagnostics in the hot path.
PasswordForgotProcessor's commented [Diag-PF] lines cleaned up too.
No changes in redb.Identity itself — a rebuild that re-pins the dependency on redb.Route.
redb.Identity.Core 3.5.0 pins redb.Route.Http 3.5.0, the build that still contains
SharedHttpServerManager privately; redb.Route 3.5.1 moved it into redb.Route.Http.Hosting so
HTTP-based connectors can share one Kestrel per host:port. Identity is an HTTP-facing product
hosted next to other connectors inside a Tsak worker, so a stale pin here is not academic: with the
old Http, an AS2 endpoint and the OIDC facade could not sit on the same port, and NuGet would not
have corrected it on its own (redb.Route.As2 depends on Http.Hosting, not on Http).
Nothing else changed — same code, same behaviour, rebuilt on redb.Route 3.5.1 and redb.Tsak
3.5.1. redb core stays 3.5.0: it does not reference redb.Route at all.
No changes in redb.Identity itself — a rebuild onto redb 3.5.0 / redb.Route 3.5.0 / redb.Tsak 3.5.0, released with the ecosystem.
The dependency refresh is the point. Identity runs as .tpkg modules inside a Tsak worker, and that
worker loads the redb framework from its shared layer, which is gated on the minor version — so
an Identity built against 3.4.0 cannot be hosted on a 3.5.0 runtime, and vice versa. Rebuilding also
carries two redb.Core correctness fixes into every Identity deployment: a props-cache entry that
could be served stale after in-place mutation, and RedbHash being order-dependent for Dictionary
properties (both detailed in the root CHANGELOG.md). Identity stores clients, users and signing
keys through that same layer, so neither is academic here.
Why the jump from 1.2.2 to 3.4.0 — aligning with the ecosystem, not a breaking rewrite. Until now redb.Identity carried its own 1.x line while the rest of the stack (
redbcore,redb.Route,redb.Tsak) moved together on 3.x. Running two numbering schemes made "which Identity works with which core/Route/Tsak" a lookup instead of a glance. From now on redb.Identity shares the ecosystem version: it jumps straight to 3.4.0 to sit on the same number as theredb.Route3.4.0 it is built against, and every future ecosystem release bumps Identity with it.This is a version realignment, not a semver-breaking release — there are no breaking API changes here (the only feature, JAR, is off by default and additive; see below). The major digit changes because the number is now the ecosystem's, not because Identity's contract broke. The old 1.x tags remain valid history; 3.4.0 simply continues the same package on the shared number.
- JWT-Secured Authorization Request — JAR (Z7 / RFC 9101), off by default.
/connect/authorizenow accepts a signed authorization request, gated behindFeatures.EnableJar(defaultfalse, so every prior release behaves identically — arequestparameter is still answered withrequest_not_supported). When enabled:- a signed
requestobject (inline) orrequest_uri(by reference, fetched over HTTP) is verified against the client's registered keys, and the parameters inside the JWT take precedence over the query string (RFC 9101 §6.1); alg: noneis never accepted — an unsigned request object drops the integrity guarantee JAR exists for; nor is a wrong-key signature, a mismatched innerclient_id, an expired object, or (when the client declaresRequestObjectSigningAlgunderJarEnforcementMode=Enforce) a mismatched algorithm. Every failure isinvalid_request_object;request_urifetches are SSRF-guarded (OutboundUrlGuard) and size/timeout-bounded; PAR'surn:ietf:params:oauth:request_uri:*is left untouched;- discovery advertises
request_parameter_supported,request_uri_parameter_supportedandrequest_object_signing_alg_values_supported— only when JAR is on, so discovery never promises what the endpoint won't do. - The formerly write-only client fields
RequestObjectSigningAlgetc. now do something (see below). - New config:
JarOptions(Jarsection) — enforcement modeOff/LogOnly/Enforce, allowed algorithms (asymmetric only), clock skew, size limits, and therequest_uriSSRF knobs. - Implemented as OpenIddict server handlers (
ValidateRequestObjectHandler), replacing the built-in handlers that unconditionally rejectedrequest/request_uri. 15 handler tests + a live conformance demo (demo_jar_request_object.ps1, wired intorun_all).
- a signed
- Client public-key resolver —
IClientKeyResolver(Z7 / RFC 9101, phase 1). Resolves the keys that verify what a client signed: a JAR request object and aprivate_key_jwtassertion. Two sources — the inlineApplicationProps.JsonWebKeySet, orJwksUri, fetched and cached viaConfigurationManager<JsonWebKeySet>(background refresh on a TTL plus a rate-limited forced refresh on akidmiss, so a client rotating its keys does not break sign-in until the TTL expires).- Fails closed. An unreachable or malformed JWKS yields no keys, so there is nothing to verify against and the caller rejects. "Could not check" is never treated as "valid".
- A configured-but-broken inline JWKS does not fall back to
jwks_uri. A typo in the pasted key set must stay visible instead of being masked by a silent switch to another key source. - Asymmetric algorithms only. HMAC (
HS*) is impossible:ClientSecretis stored as a BCrypt hash and verifying an HMAC needs the original secret. FAPI 2.0 forbidsHS*for request objects anyway. - The service is inert for now — registered but called from no path, so server behaviour is
unchanged. Wiring it into JAR is phase 2; into
private_key_jwt, a separate step.
OutboundUrlGuard— SSRF filter for URLs that arrive from outside (jwks_uri, later the JARrequest_uri). Rejects anything that is not an absolute HTTPS URL, and anything resolving to a non-public address: loopback, RFC 1918, link-local — including169.254.169.254, the cloud metadata endpoint that hands instance credentials to whatever can reach it — CGNAT, IPv6 unique-local, and the IPv4-mapped forms (::ffff:10.0.0.1) that a naive check walks straight past. The check runs before a socket is opened. Known limitation: this does not close DNS rebinding, which would require pinning the validated address onto the connection itself.ClientKeysOptions(ClientKeyssection ofRedbIdentityOptions) — cache lifetime, minimum refresh interval, fetch timeout, maximum document size,RequireHttps,AllowPrivateNetworkTargets. The last two are development-only relaxations.- 40 tests (
tests/Security/): 29 for the SSRF guard — including one asserting that 172.32 and 172.15 are public and must NOT be blocked — and 11 for the resolver. All stay offline: attempting a network call fails the test by itself.
doc/JAR_RFC9101_PLAN.md— JAR implementation plan: six phases, risks (SSRF,alg:none, algorithm substitution), a stagedOff → LogOnly → Enforcerollout, and sizing. Records a verified finding: OpenIddict 6.3.0 does not support request objects — its assembly carriesValidateRequestParameterand therequest_not_supportedstring, but nothing that parses a request object or checks its signature — andrequest_uriexists only as a PAR URN.Z7now links here.ApplicationProps.RequestObjectSigningAlgis now enforced underJarEnforcementMode=Enforce(was previously stored and ignored)....EncryptionAlg/...EncryptionEncremain stored-only — request-object encryption (JWE) is the deferred phase 4.JwksUriis now resolved (phase 1).
Advertising JAR changed the local Basic-OP run from 1 SKIPPED / 4 REVIEW to 2 SKIPPED / 3 REVIEW
— still 0 FAILED. This is a net improvement, not a regression, and OPENID_CERTIFICATION.md §4.3
explains it in full for anyone reading the badge:
- Both SKIPPED modules test the unsigned (
alg:none) request object. The suite skips them when the server does not advertisenoneinrequest_object_signing_alg_values_supported— which is exactly our stance: we support signed request objects only, becausealg:noneis an unsigned JWT that defeats JAR's integrity guarantee and is forbidden by FAPI 2.0. SKIPPED here = "the server declined an unsafe mode", the secure answer. - One module (
ensure-request-object-with-redirect-uri) moved REVIEW→SKIPPED precisely because the server now genuinely processes signed request objects and advertises that honestly, instead of the earlier no-support state that sent it down a screenshot path. Turning either skip into a pass would require acceptingalg:none— a security regression we will not make.
Why the bump. No functional changes to redb.Identity — this release exists to pick up the fixed redb storage, and it is a patch because only dependencies moved.
redb.Identity.Core1.2.1 depends onredb.Core3.3.0, whose embeddedredb_init.sqlcarried anALTER FUNCTION ... OWNER TO postgresthat failed schema initialization under a non-superuser database owner — the whole first-start init rolled back. Identity is redb-backed (every store — Application, Authorization, Token, Scope, KeyRing, Session, Audit — lives in redb storage), so an Identity deployment on a least-privilege database could not start. The fix landed inredb.Core3.3.3, and without this rebuild it would never reach Identity users.1.2.2 is built against redb.Core / redb.Route / redb.Tsak 3.3.3 (was 3.3.0 / 3.3.1 / 3.3.1).
Why not 3.3.3. redb.Identity keeps its own 1.x line and does not follow the ecosystem number — but it is released together with the ecosystem, because it depends on redb storage. A fix in redb core is always a redb.Identity release too.
Token issuance is atomic again — and the addon story is now true in a shipped build. 1.2.0
documented (and the launch articles describe) an addon that mints a token through
direct-vm://identity-token inside its own WithRedbTx, committing its domain write and the token
issue as one transaction. In 1.2.0 that was not actually the case in the .tpkg topology — the fix
is here.
-
The OpenIddict stores now enlist in the route transaction. In the
.tpkgtopology Identity resolves its services from a child container, and that container used to open a host scope of its own per DI scope — a second DB connection. A route-level redb transaction runs on the connection redb.Route caches on the exchange, so the token/authorization store writes landed on a different connection: no atomicity, and — because the second connection blocked on the row locks the first one held while the first awaited the call that opened the second — a self-deadlock that cleared only on the 30-second transaction timeout (~34 s on SQLite via the busy-timeout × retry cascade). This is whyWithRedbTxhad to be stripped from the token route in 1.2.0. It was never a SQLite quirk: the deadlock rule is written about Npgsql; SQLite's single writer only made it louder.The child scope now binds its
IRedbServiceto the exchange's instance (a newIdentityExchangeAccessorcarries the exchange in), so the stores write on the same connection the transaction was opened on.WithRedbTxis restored on the token route: the token entry and the authorization entry either both land or neither does. And an addon that wraps its own route inWithRedbTxand callsdirect-vm://identity-tokennow gets one atomic commit across its own write and Identity's token issue — the claim the docs make, now real. Resolution is also lazy now: a child scope that never touches the DB no longer pays for a connection. -
Token route was silently losing its RouteId. Caught by
RouteTransactionMarkingTestswhile restoring the wrap:WithRedbTx(From(...)).RouteId(...)set the id on the transaction definition, not the route, soidentity-tokenwould have registered with no id — breaking cluster locks, per-route metrics and the dashboard. Every demo still passed; only the guard test saw it. Fixed by moving.RouteId(...)insideFrom(...).
- Out-of-route callers (cleanup timers, hosted services, schema init) still get their own scope —
there is no ambient transaction to join, and taking somebody else's connection would be worse than
the bug. Covered by a new test (
ChildScope_WithoutExchange_GetsItsOwnRedbService), and the enlistment itself is proven byChildScope_BoundToExchange_ResolvesTheExchangeRedbService, which was verified to go red when the fix is disabled — a guard that cannot fail is not a guard. - The other six routes that carry no
WithRedbTx(Login, Authorize, Revoke, MfaVerify, MfaRecovery, MfaManage, ManageApps) are correctly left unwrapped and their comments now say why: a segment transaction already inside the processor (MFA), a single atomic store operation (Authorize / Revoke / apps), or a single write behind Argon2id where a route-level wrap would hold the writer lock over the CPU verify for no atomicity gain (Login). Seedoc/PERF_RULES.mdrule 1. - Test suite:
Passed: 1769, Skipped: 1, Failed: 0on PostgreSQL, MSSQL and SQLite.
Basic OP: 35 modules, zero failures. The official OpenID Foundation conformance suite was run against a live redb.Identity end to end — and it found real defects, which are fixed below. We also audited our own RFC catalogue line by line against the code, live discovery and the demo probes; what could not be proved was either built or struck. Both outcomes are in this release.
| Result | Modules |
|---|---|
| PASSED | 29 |
| REVIEW | 4 — manual-screenshot tests, pass-equivalent |
| WARNING | 1 — two deliberate id_token claims (see Notes) |
| SKIPPED | 1 — request objects (RFC 9101), which we do not advertise |
| FAILED | 0 |
- Scope-derived claims are no longer embedded in the id_token. In the
authorization-code flow the
profile/email/phone/addressclaims are now delivered from/connect/userinfoonly, per OIDC Core §5.4. This was a PII leak: an id_token is routinely forwarded to third parties and logged as proof of the authentication event, so the user's e-mail, phone and address travelled considerably further than the RP intended — and the conformance suite flags it as "may result in user data being exposed in unintended ways". If your RP read those claims out of the id_token, it will now read empty. Fetch them from/connect/userinfowith the access token (the RP loses nothing — the same claims are served there), or, if you need a specific claim in the id_token, request it explicitly via the newclaimsparameter (below):claims={"id_token":{"email":null}}. The id_token keeps what identifies the authentication event:sub,auth_time,acr,sid,nonce,at_hash.
- OIDC Core §5.5 — the
claimsrequest parameter. An RP can now name the exact claims it needs instead of pulling in a whole scope to get one of them, and choose the delivery channel: theuserinfomember or theid_tokenmember.essential,valueandvaluesqualifiers are honoured. A claim we hold no value for is omitted (Essential is a statement of need, not a licence to invent); avalue/valuesconstraint we cannot satisfy means the claim is omitted rather than answered with a different value. Aclaimsrequest that pinssubto a different End-User is refused. Discovery now advertisesclaims_parameter_supported: true— it saidfalsebefore, honestly. Probe:demo_claims_parameter.ps1. - OIDC Core §5.1 — the complete
profileclaim set. All 14 claims, withupdated_atas a JSON number and the*_verifiedflags as JSON booleans, as the spec requires.UserPropsgains the missing OIDC fields — a props change, so no migration. - RFC 7643 §4.3 — SCIM Enterprise User extension.
department,manager,employeeNumber,costCenter,organization,division. This is what corporate provisioning actually sends: Okta, Entra ID and Workday push it on the very first sync, and a provider that advertises only the core schema makes them drop that data on the floor.manageris a complex attribute; its$refanddisplayNameare derived, not stored — §4.3 marks displayName read-only, and a stored copy would rot the moment the manager is renamed. Probe:demo_scim_enterprise.ps1. - RFC 8252 §7.3 — loopback redirect URIs, port not compared. Without this no
native or CLI client can use this provider at all: they ask the OS for an
ephemeral callback port at launch, so the port differs on every run and cannot
be registered in advance. The widening is exactly one port wide — it applies
only to a client that already registered a loopback URI; the host must be the
literal
127.0.0.1or[::1](not the namelocalhost, which §8.3 warns can be resolved elsewhere); userinfo in the URI is refused; and scheme, host, path, query and fragment must still match exactly. Toggle:RedbIdentityOptions.AllowLoopbackRedirectPortWildcard(defaulttrue— §7.3 is a MUST, not an opt-in). Probe:demo_loopback_redirect.ps1, deliberately mostly negative. - HTML error page at
/connect/authorize. RFC 6749 §4.1.2.1 says that when theredirect_uriis missing, unregistered or malformed the server must not redirect — it has nowhere trustworthy to send the user, so it must inform the resource owner itself. We already refused to redirect; we just informed the resource owner with a raw JSON error object. A human staring at{"error":"invalid_request"}has not been informed. Content-negotiated: browsers get the page, anything sendingAccept: application/jsonor*/*(curl, SDKs, the conformance suite) gets the byte-identical JSON it got before.
- UserInfo leaked token plumbing. The response was copying claims through a
deny-list, so OpenIddict internals (
oi_*) and JWT protocol claims (jti,exp,iat,at_hash,nonce…) rode along into the userinfo document. Those describe the token, not the user; UserInfo (§5.3) returns the user's claims and nothing else. Now an explicit allow-list. /connect/userinfo(POST) rejected the access token in the request body (RFC 6750 §2.2) — the route was missing form-to-body mapping.prompt=nonecould still surface a login form. When aclaimsrequest pinnedsubto a different End-User, the rejection was deferred to the local/loginpage even though the RP had explicitly forbidden any interactive UI (OIDC §3.1.2.6). The error now flows back to the client'sredirect_uri.- SCIM discovery 404 under the management prefix.
/api/v1/identity/scim/v2registered only the list endpoints —ResourceTypes/{id}andSchemas/{id}were missing. A provisioning client is pointed at one base URL, walks ResourceTypes and then fetches each Schema by id — and got a 404 on the first schema it asked for. oi_au_idno longer leaks into the id_token (kept on the access_token, where introspection needs it).
- The RFC catalogue in the docs is now audited, not asserted. Five rows were
provably false. Three of them we chose to build rather than delete (the
claimsparameter, SCIM Enterprise, loopback). Two were struck, with the reasoning stated plainly:- Front-Channel Logout 1.0 — it signs RPs out through an iframe to each
client, so 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. We
ship Back-Channel Logout — server to server, signed logout token, plus a
pull feed of revoked
sids for multi-replica RPs. It is strictly better and cookie-independent. The working mechanism beats the checkbox. - HOTP (RFC 4226) as a standalone method — counter-based OTP is effectively unused; the world runs on TOTP. RFC 4226 lives on as the foundation of our TOTP (160-bit secret per §4), which is what we now say.
- Front-Channel Logout 1.0 — it signs RPs out through an iframe to each
client, so 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. We
ship Back-Channel Logout — server to server, signed logout token, plus a
pull feed of revoked
- Two conformance warnings are deliberate.
oidcc-serverfinishes WARNING on two non-requested id_token claims. These are warnings, not failures — OIDC Core does not forbid extra id_token claims, and the suite's own message concedes it may be seeing "an extension the conformance suite is not aware of". Neither is data about the user:oi_tkn_idis the token's store-entry id, and it is what makes the id_token revocable and drives back-channel logout;redb:user_idis a namespaced private claim (RFC 7519 §4.3) that lets a client join our internal bigint to its own user table without a round-trip. In the same run we deleted a third such claim (oi_au_id), because it was OpenIddict's internal authorization link, meaningless to a client, and there was nothing to defend. Keeping two and cutting one is a judgement, not a rationalisation. - Test suite:
Passed: 1767, Skipped: 1, Failed: 0on SQLite; one codebase across PostgreSQL / MSSQL / SQLite.
Preparing for OpenID Certification. We
stood up the official OpenID Foundation conformance suite against a live
redb.Identity and used it to validate — and harden — the OIDC / OAuth surface
end to end. Full state and per-module breakdown:
OPENID_CERTIFICATION.md.
- OpenID conformance — local runs of the official OIDF suite. Config OP reports zero failures over native HTTPS; the Basic OP authorization-code-flow modules pass. Every automatable module is green — the remainder are interactive re-auth flows (which behave to spec) and automation limits, not server gaps.
- .NET suite still green across all three providers —
Passed: 1767, Skipped: 1, Failed: 0(PostgreSQL / MSSQL / SQLite), one identical codebase. - Demos run over HTTPS out of the box. The
demos/suite is base-URL-switchable ($env:IDENTITY_BASE) and TLS-clean end to end.
- Native HTTPS for the HTTP facade — TLS terminated in-process (Kestrel via
the redb.Route.Http connector:
ssl=true+ cert path in config), no reverse proxy required. SeeHTTPS.md. OPENID_CERTIFICATION.md— what redb.Identity implements toward OpenID Certification and the current conformance state.- Configurable PKCE (
RedbIdentityOptions.RequirePkce) — enforce proof-key per client, or relax for a non-PKCE Basic-profile run; S256-only when present.
- Authorization error delivery (RFC 6749 §4.1.2.1). Errors are returned via
the client's registered
redirect_urionly — validated against the client before any redirect — closing an error open-redirect and deliveringinvalid_scope/invalid_requeston the correct channel. - Token-endpoint error codes (RFC 6749 §5.2). A reused / already-redeemed
authorization code now returns
400 invalid_grant(was401 invalid_token);invalid_clientmaps to 401. Cache-Control: no-store+Pragma: no-cache(RFC 6749 §5.1) on the token, PAR, introspection, revocation and device-authorization responses; discovery and JWKS stay cacheable by design.prompt=login/max_agere-authentication (OIDC §3.1.2.1). Both route the End-User back to/loginand complete on re-login — never leaked as an error to the RP. The signed re-auth marker is bound to the session id, so there is no redirect loop and no same-second bypass.- Boolean claims (OIDC §5.1).
email_verified/phone_number_verifiedare emitted as JSON booleans, not strings;acras"1"/"2". - id_token hygiene. OpenIddict's internal
oi_au_idauthorization-reference claim no longer leaks into the id_token (kept on the access_token for introspection linkage). - Discovery metadata completeness —
acr_values_supported,claim_types_supported, and the fulltoken/introspection/revocation_endpoint_auth_methods_supportedsets.
First public source release of redb.Identity.
- Full cross-provider parity. The test suite (1768 tests) is green on
all three redb storage providers — PostgreSQL, Microsoft SQL Server and
SQLite — from one identical codebase:
Passed: 1767, Skipped: 1, Failed: 0. The single skip is a known PostgreSQL-specific test-host teardown probe, not a product gap. The provider is chosen by the host worker; Identity references only theredb.CoreOSS abstraction and never a concrete provider.
- Public-repo framing (README). Documented the runnable
demos/suite and itsdemos/run_all.ps1driver; listed the SQLite provider alongside PostgreSQL / MSSQL; reconciled the RFC catalogue against the source tree; and made the deployment story explicit up front — the engine ships as.tpkgpackages for redb.Tsak, HTTP is merely the first transport facade, any in-process module can call Identity overdirect-vm://with zero network, andredb.Identity.Webis a reference BFF (Contracts + Client only, never Core/Http). - Publication tooling.
scripts/publish-identity-public.ps1prepares the public repo — copies the nine OSS source projects, the demo suite, the dev / build helper scripts and the fulltests/tree (all three test projects, so the 1767-passing multi-provider suite ships for the community to read and run), patches cross-reporedb.Core*/redb.*provider /redb.Route.*/redb.Tsak.*ProjectReferences to NuGet PackageReferences, strips the Proredb.licenseand sanitises sample config, and excludes onlydoc/(internal notes). A Cyrillic guard blocks the push if any non-English text slips in.
⚠️ Not yet published to NuGet. This is the first tagged preview of redb.Identity. The engine has been running in internal Tsak deployments through the entiredirect-vm://-first design phase; this entry collapses that history into a single shipping milestone. The headline change in this preview is the GUID-sub migration: the public OIDCsubclaim is now a stable opaque GUID stored onRedbObject<UserProps>.value_guid, and the internal bigint user id is exposed only through the privateredb:user_idclaim. All redb-backed OpenIddict stores (RedbApplicationStore,RedbAuthorizationStore,RedbTokenStore,RedbScopeStore) and every claim-issuing handler were updated together so the wire never leaks the internal id, while in-process consumers that key off_users._idcontinue to work via theredb:user_idclaim. The full test suite (1783 tests) is green on this preview.
redb.Identity — W1: outbound webhook subscriptions (HMAC-SHA256 signing + retry + secret rotation + admin UI)
Closes a long-standing gap vs WSO2 IS 7.x's "Outbound Provisioning" surface. Operators wire up HTTP endpoints, pick which events should reach them, and we POST signed JSON bodies. Full end-to-end implementation, not a workaround over the existing audit multicast targets.
Storage (WebhookSubscriptionProps): URL, display name, description,
event-type filter (* / comma-separated event ids /
cat:<category>), enabled flag, HMAC secret (server-generated on
create, rotated through a dedicated endpoint), per-attempt timeout,
max attempts, exponential backoff base, optional extra HTTP headers,
opaque concurrency token.
Service (WebhookSubscriptionService): CRUD with URL validation
(absolute http/https, no fragments), idempotent + concurrency-token
bump on every write, ListEnabledAsync for the delivery consumer's
per-event bulk load, static Matches for filter evaluation
(* / event id list / cat: tokens, also comma-separable).
Admin API:
- Route:
direct-vm://identity-manage-webhookswith the standard create / list / get / update / delete + dedicatedrotate-secretoperation. - HTTP:
/api/v1/identity/webhooks[/{id}]+POST /webhooks/{id}/rotate-secret. Gated byidentity:applications.manage. - Client SDK:
IIdentityClient.{List,Get,Create,Update,Delete,RotateWebhookSecret}WebhookAsync. - HMAC secret returned only on the create + rotate responses; all
other responses carry
HasHmacSecret: true/falseinstead of the value. - Audit emits:
WebhookSubscription{Created,Updated,Deleted,SecretRotated}.
Delivery (WebhookDeliveryProcessor): consumes the events route as
the third sink (after the relational audit so the local trail is
durable BEFORE we fan out). Per event: bulk-load enabled subscriptions
(once per event), filter-match each, then fire-and-forget a
Task.Run per matching subscription so a slow receiver never
back-pressures the originating mutation. Signature is
HMAC-SHA256(secret, body) — GitHub-style, covers raw body bytes
only (no canonical-payload-recomposition pitfalls; receivers verify
with the standard recipe). Headers per delivery:
X-RedbIdentity-Signature = sha256=<hex> /
X-RedbIdentity-Timestamp (ISO-8601 UTC) /
X-RedbIdentity-Delivery (per-attempt GUID — receiver-side dedupe
key) / X-RedbIdentity-EventType / X-RedbIdentity-Attempt. Reserved
X-RedbIdentity-* headers ignore clashes from operator-supplied
ExtraHeaders. Per-subscription exponential backoff between retries
capped at 30s; final-attempt failure logs at error level with the
last status code + delivery id so operators can grep / alert.
UI:
/admin/webhooks— paginated list with per-row URL + filter + enabled badge + retries summary; Create modal; row click to detail. After Create the HMAC secret is shown ONCE in a modal — copy now or use Rotate later./admin/webhooks/{id}—UiTabBarwith Overview + Settings tabs. Overview surfaces the signing recipe (which headers, how to recompute HMAC) so receiver-side engineers can verify without reading our source. Settings edits URL, filter, timeout, max-attempts, backoff, extra headers; Rotate-secret button opens the new-secret modal.- Sidebar nav link between Roles and Federation.
Demo demos/demo_webhooks.ps1 — 12 / 12 PASS:
spin up pwsh HttpListener mock receiver on a free local port;
create subscription pointed at it with EventTypeFilter=UserCreated;
trigger POST /users; wait for delivery + verify HMAC; rotate
secret + trigger again + verify NEW secret signs (and OLD secret
REJECTS so we know rotation took effect on the live consumer);
delete subscription + trigger + assert no further delivery. Wired
into run_all.ps1 right after demo_role_permissions.ps1.
redb.Identity — federation: placeholder providers hidden from /login + DEPLOYMENT setup procedure
Identity:FederationProviders ships with ClientId=REPLACE_ME
placeholders for Google / Microsoft / Keycloak / GitHub so the SDK
compiles + tests pass without an external IdP account. Users clicking
the Google button on a fresh deploy landed at Google with
client_id=REPLACE_ME and saw Google's "OAuth client was not found"
401 — a confusing "is it the server or my config?" failure mode.
Fix: filter providers whose ClientId or Authority still contains
REPLACE_ME at the two surfaces the user can reach them from. The
DI registration (RedbIdentityServiceExtensions.AddRedbIdentityServer)
skips placeholder providers entirely, so the
IFederatedAuthProvider DI list only contains configured ones and
FederationChallengeProcessor's "Unknown federation provider" path
fires if someone crafts a direct /auth/federation/google/start URL.
The /api/v1/identity/federation-providers/public endpoint applies
the same filter, so /login renders only configured buttons. Adding
Google later is reload-driven, not restart-driven.
New ## Federation providers — operator setup section in
doc/DEPLOYMENT.md walks through GCP / Microsoft / Keycloak / GitHub
account setup step-by-step: exact redirect URI to register, env-var
override recipe for ClientSecret, mock-idp-e2e dev path for offline
CI, curl probe to verify the placeholder-filter is doing its job.
redb.Identity — B.3: first-class Roles registry (org / app audience + direct & transitive assignments + audience-scoped roles claim)
Roles used to ride implicitly on the claim-mapper pipeline (group →
mapper → roles claim) — fine for simple deployments, painful as soon
as the operator wanted "give every member of dev group the
shop-admin role on app A but NOT app B". B.3 lands a first-class
registry: roles exist as their own audience-aware entities, get assigned
to users directly and to groups transitively, and emit a canonical
roles claim filtered by the requesting client at token issuance.
Schema (3 new redb props types, registered in IdentitySchemaRegistry
-
explicitly registered in the CLR type registry by
IdentitySchemaInitListenerso polymorphic loads through the tree APIs don't trip):RoleProps(identity.role) —Name,DisplayName,Description,Audience(organization|application),ApplicationId,IsSystem. The (Name, Audience, ApplicationId) triplet is the uniqueness key — rename = delete + re-create.UserRoleAssignmentProps(identity.user_role_assignment) —parent_id= role's _objects.id,key/value_long= userId. One row per (role, user) pair, idempotent at the service level.GroupRoleAssignmentProps(identity.group_role_assignment) —parent_id= role's _objects.id,key/value_long= groupId.
Service (RoleService): role CRUD with server-side uniqueness check
and system-role-deletion gate; paged search (name pattern + audience
- applicationId filters); bulk assignment-count for the list-page
badge; idempotent
AssignUser/AssignGroupwith their unassign counterparts;ListAssigneesAsyncreturning both subject kinds;GetEffectiveRolesAsync— the read path called at token issuance, which unions direct user assignments with assignments inherited via the user's group memberships, filters out audience='application' roles that don't match the requesting client_id, and returns bulk-loaded role objects.
Admin API:
- Route:
direct-vm://identity-manage-roles(operations search / get / create / update / delete / list-assignees / assign-user / unassign-user / assign-group / unassign-group). - HTTP:
GET/POST/PUT/DELETE /api/v1/identity/roles[/{id}]+/assigneesand the assign endpoints. Gated byidentity:applications.manageinGranularScopeGuardProcessor. - Client SDK:
IIdentityClient.{Search,Get,Create,Update,Delete}RoleAsync{Assign,Unassign}{User,Group}{To,From}RoleAsync+ListRoleAssigneesAsync.
Token issuance:
AttachRoleRegistryClaims— new OpenIddict handler onProcessSignInContext, registered atAttachAuthorization.Descriptor.Order + 800(after claim mappers +500 and claim-definition enforcement +700). Resolves the application id fromclient_id, walks the user's group memberships viaGroupService, callsRoleService.GetEffectiveRolesAsync, and emits arolesclaim per unique role name with explicit destinations on BOTH access_token and id_token (OpenIddict silently drops claims without destinations once the principal builder has finished its switch — discovered the hard way; fix folded in).- Behaviour by grant: password / authorization_code / refresh / device_code → emitted; client_credentials no-ops.
Admin UI:
/admin/roles— searchable + paginated list with audience filter, per-row assignment-count badge, edit / delete shortcuts; system roles can't be deleted from the row./admin/roles/new— 2-step wizard (Basic Details → Permission Selection placeholder pointing at/admin/claim-scopesas the permission registry). Audience radio with debounced application picker when Application is chosen./admin/roles/{id}— Overview / Users / Groups / Settings tabs. Users and Groups tabs share the same pattern: debounced search filtered to non-assignees, click-to-assign, per-row remove. Settings edits displayName + description; name + audience are immutable.
Sidebar nav link added between "Claim definitions" and "Federation".
Demo demos/demo_roles_registry.ps1 — 18 / 18 PASS:
- Create org + app-audience roles, assign user directly, ROPC →
rolesclaim contains both roles on the matching app, only the org role on the OTHER app (audience='application' role doesn't leak across applications). - Create a group, add user, assign the GROUP to a third role, ROPC
→
rolesclaim also includes the transitive role.
Wired into run_all.ps1 between
demo_claim_definitions_per_app.ps1 and
demo_session_lifecycle.ps1.
redb.Identity — B.2: Groups admin page (paginated search + per-group detail tabs + 2-step create wizard)
The pre-existing /admin/groups was a two-pane tree explorer that
worked well for small hierarchies but stopped scaling the moment the
operator wanted to find a single group across hundreds of nested
groups. B.2 ports the WSO2 IS 7.x pattern end-to-end: flat searchable
list + per-row badges + a 2-step create wizard + a dedicated detail
page reachable by row click.
Server side:
GroupService.SearchGroupsAsync(name, type, offset, count)+CountMembersByGroupAsync(ids)— the latter is one bulk query for every visible row's member-count badge, replacing what would otherwise be N round-trips.GroupManagementProcessorgains thesearchoperation;GroupResponseDTO growsModifiedAt+MemberCount;MapGroupWithMembersattaches the bulk-counted MemberCount for list responses. The existinglist(root-only) is left untouched for back-compat with internal tooling.- HTTP:
GET /api/v1/identity/groups/search?query=&groupType=&offset=&count=. - Client SDK:
IIdentityClient.SearchGroupsAsyncreturningPagedResult<GroupResponse>.
Admin UI:
Groups.razor— full redesign. Debounced name search, type filter (team / organization / department), per-row member-count chip + last-modified timestamp, edit + delete shortcuts. Row click navigates to the per-group detail page. Pagination controls disabled while loading.GroupCreateWizard.razor— 2-step modal (Basic Details / Add Users). Step 1 has name + type + description + an optional parent picker (debounced search across the hierarchy) AND an inline user picker for initial members. Members are added best-effort after the group is created — a single member failure does not roll back the group itself, just toasts. Step 2 is a placeholder referencing the dedicated/admin/rolespage for role assignment.GroupDetail.razor(new route/admin/groups/{Id:long}) — Overview / Members / Subgroups / Settings tabs. Members tab has an inline user search picker (filtered to non-members), per-row role selector with inline save, per-row remove. Subgroups tab lists direct children with click-to-navigate. Settings tab is the rename / re-type / re-describe form.
The Audit per-user filter and the existing tree ancestor expansion
(fixed in the 33f3fbd7 CLR-type-registry-at-startup commit) carry
through these UI changes without modification — the redesign is
purely above the API surface that those fixes target.
OpenIddict's default introspection serialiser only emits the standard
RFC 7662 claim set (active, sub, jti, iat, nbf, exp,
scope, aud, …). The internal bigint user id has ridden on the
principal as redb:user_id from day one (see
IdentityPrincipalBuilder.InternalUserIdClaim) and was already in the
issued JWT, but external resource servers calling /connect/introspect
to authorise a request had no way to read it without decoding the token
themselves.
AttachInternalUserIdToIntrospectionResponse (new OpenIddict handler
on HandleIntrospectionRequestContext) reads redb:user_id off
context.Principal and writes it into context.Claims, which the
Apply* writer flattens into the JSON response alongside the standard
fields. Runs late in the handle stage (order int.MaxValue - 1000)
so OpenIddict's standard projection has populated the dictionary
first. Registered just before ApplyIntrospectionResponseHandler in
RedbRouteOpenIddictServerBuilderExtensions so the ordering
dependency is visible.
RFC 7662 §2.2 explicitly authorises implementation-defined top-level
response members; the redb: prefix follows RFC 7519 §4.3
private-claim naming so it won't collide with future IANA-registered
names.
Behaviour by grant type:
password/authorization_code/refresh_token/device_code— introspect now returnsredb:user_idalongside the GUIDsub.client_credentials— principal carries noredb:user_id(there is no user), key absent.- inactive / revoked tokens — principal is null, handler no-ops.
Probe (ROPC against a fresh DCR'd client):
POST /connect/token → access_token.
POST /connect/introspect → 200 with
{ "active": true, "sub": "<GUID>", "redb:user_id": "1422477", … }
with redb:user_id matching the admin-side bigint that
POST /users returned.
redb.Identity.Core — S2 claim definitions: declarative schema for custom claims (global + per-application)
Custom claims used to be a bare per-user Dictionary<string, string> on
UserProps.CustomClaims with no validation. S2 layers a typed schema on
top — three orthogonal mechanisms now compose cleanly:
- Per-user value storage (existing) — what a specific user has set.
- Per-application schema (new) — required claims a target client must see in the issued token, regardless of who's signing in.
- Global identity schema (new) — required claims every user in this Identity instance carries.
ClaimDefinitionProps (identity.claim_definition): ClaimName,
DisplayName, Description, Type ∈ {string,int,long,bool,datetime,
url,email}, Required, DefaultValue, ValidationPattern (regex),
Scope ∈ {global, application}, ApplicationId, EmitOnIdToken,
EmitOnAccessToken. The (ClaimName, Scope, ApplicationId) triplet is
unique; rename / re-scope = delete + re-create.
Server-side enforcement:
ClaimSchemaValidator.EnforceGlobalAsyncis the single rule pipeline shared by both the adminUserManagementProcessor.Create/Updateand the self-serviceAccountRegisterProcessor.Register. Required + missing + no DefaultValue → 400; required + missing + DefaultValue → default silently applied; present → type parse + regex check. The register-side gate runs BEFORE coreUser creation so failed schema never leaves a half-created user behind.AttachClaimDefinitionEnforcement(new OpenIddict handler, runs afterAttachClaimMapperClaimsat order +700) gates token issuance: per-application required claims missing →context.Rejectwithinvalid_requestand a precise description; required + DefaultValue →AddClaimso the token still ships; matched claim names also get their destinations rewritten per theEmitOnIdToken/EmitOnAccessTokenflags (default is both tokens).
Admin surface:
- Route
direct-vm://identity-manage-claim-definitions(create / list / get / update / delete), gated byidentity:applications.manageinGranularScopeGuardProcessor. - REST
/api/v1/identity/claim-definitions+ paged list withscope/applicationIdfilters. - Client SDK
IIdentityClient.ClaimDefinitionspartial. CreateUserRequestgainsCustomClaimsso admin can satisfy required global claims at create time in one POST (matches the WSO2 IS 7.x pattern).internal_user_idclaim (redb:user_id) also lands on theid_tokennow (was access_token only) so external integrations can decode the id_token client-side and cross-reference the internal bigint without a second/userinforound-trip.
Admin UI:
/admin/claim-definitionsBlazor page — pagination, scope filter, create / edit modal exposing every definition field. Badges show type / scope / required / default / destinations on each row.UserProfileTab"Custom claims" section now schema-driven: loads global definitions, renders ONE typed input per definition (number for int / long, checkbox for bool, datetime-local for datetime, type='url'/'email' for those, plain text with the regex onpatternfor string). Required indicator + description hint + inline empty-value warning when no DefaultValue is set. Unstructured ad-hoc claims (anything with no definition) get a separate section so legacy / one-off keys aren't silently dropped.- Editor falls back to ad-hoc-only mode when
/claim-definitionsis unreachable, so the Profile tab stays usable across permission revokes / definition-page outages.
Demos: demo_claim_definitions.ps1 (12 / 12) — admin CRUD + global gate
on user create / update + register; demo_claim_definitions_per_app.ps1
(11 / 11) — per-application required gate + EmitOnIdToken=false dropping
the claim from id_token while access_token still carries it. Both wired
into run_all.ps1.
Audit used to persist via AuditEventProps rows scattered across
_objects + _values — an append-only flat shape running through a
PVT store, which inflated row counts and forced per-user filter queries
to scan EAV indirection. R1 migrates the trail to a flat relational
table and goes through redb's raw-SQL API for everything except DDL.
identity_audit_log schema (id, event_id, event_type, category,
timestamp, user_id BIGINT, login VARCHAR(200), client_id,
ip_address, user_agent, details). DDL ships as embedded resource
per dialect (sql/audit/{pg,mssql,sqlite}_create_table.sql) and is
applied at module startup by IdentityAuditLogTableInitListener
through redb.Context.ExecuteAsync — idempotent CREATE / index
guards make re-runs and hot-reloads safe. user_id is BIGINT (not
varchar) so the planner runs a direct integer compare against the
already-indexed ix_audit_user_id; emitters that hand the sink a
non-numeric subject (e.g. pre-link federated google:abc123) get
their event filed with user_id = NULL after a logged warning.
AuditRelationalSinkProcessor (replaces props-based sink) issues one
INSERT per event with positional @p0..@pN parameters, reads the
canonical envelope from Out.Headers first (falls back to In for
emitters that bypass EventDispatchProcessor), and extracts the
Login field from the details JSON payload when the emitter stamped
it there. AuditQueryProcessor rewritten to raw SQL — WHERE composed
incrementally per filter (UserId AND Login = OR; either alone =
single column; everything else AND), ORDER BY timestamp DESC +
LIMIT/OFFSET, count via ExecuteScalarAsync<long>. event_id
normalised to 32-char hex regardless of dialect (PG / MSSQL return
Guid, SQLite returns text).
EventDispatchProcessor gains a UserId resolution fallback off the
identity-event-data payload so audit rows always carry user_id
even when the emitter only put it on the event-data dict. Top
user-facing emitters (UserManagement Create / Update / Delete /
ChangePassword + MeProfile + SCIM Create / Replace / Patch +
AccountRegister) now stamp UserId alongside Login —
ConsentManagement already did.
IdentityAuditOptions.Enabled defaults to true (was false); the
new sink is cheap enough — one INSERT per event vs the N-row PVT write
the props store needed — that opt-out is the right posture. Operators
who don't want a local trail set Enabled=false; external streaming
Targets remain independent.
Schema registry drops AuditEventProps; props-based sink + query +
ApplicationFixture references all deleted. The AuditEventProps row
type, sink processor, and the two now-obsolete unit / integration test
fixtures (AuditEavPersistenceIntegrationTests,
AuditEventSinkProcessorTests) are gone.
Demo: demo_groups_roles_claims.ps1 + the existing audit-touched
demos all still PASS post-migration; EXPLAIN ANALYZE on the per-user
filter confirms Index Scan using ix_audit_user_id, Index Cond: (user_id = N) with no cast.
demo_groups_roles_claims.ps1 step 11 has been failing in a long
silent-degradation mode: a user added to a child group (developers)
which had been moved under a parent (engineering) ended up with an
id_token whose groups claim contained only developers, not the
ancestor engineering. The token endpoint never erred — the trail
just stopped at the direct membership.
Root cause: GlobalMetadataCache.SchemeIdToClrType was empty in
production. TreeQueryProviderBase.LoadObjectsByIdsAsync (non-generic,
called by TreeQuery+ToTreeListAsync to load parent objects whose
scheme is only known at runtime via the JSON scheme_id field) threw
Type not found for scheme_id=N. Register type in AutomaticTypeRegistry. GroupClaimsResolver caught that throw
silently and returned an empty parent list, so ancestors never made it
into the principal.
The library's auto-scan (InitializeTypeRegistryAsync) walks
AppDomain.CurrentDomain.GetAssemblies() looking for [RedbScheme]
CLR types. In Tsak the Identity assemblies live in a child
AssemblyLoadContext; the scan's catch on
ReflectionTypeLoadException silently skips assemblies it can't
enumerate and — in production — the injected logger is null so the
warning vanishes. The registry came up empty even though every
[RedbScheme] type was clearly loaded and running code.
Fix: IdentitySchemaInitListener now explicitly calls
redb.Cache.RegisterClrType(schemeName, schemeId, type) immediately
after SyncSchemeAsync<T> for every entry in
IdentitySchemaRegistry.Types. No reliance on assembly scanning, no
race against ALC loading order, no silent failure mode. Every
polymorphic load path inside Identity — LoadObjectsByIdsAsync parent
walks, RedbObjectRow materialisation, AnyClrTypeByScheme lookups —
now succeeds for every Identity-owned scheme.
GroupClaimsResolver also gains an optional ILogger constructor
parameter so the TreeQuery degradation path emits a warning when it
does happen (transient DB error, future tree corruption) instead of
vanishing — the silent catch is kept so token issuance stays alive
when ancestor expansion fails for any reason, but the operator now
sees the regression.
Demo: 16 / 16 PASS — including step 11 (id_token.groups carries both
direct membership and tree ancestor) and step 14 (re-asserts after a
role-label rotation). Other tree-using paths (Federation provider
lookups, future B.2 Groups admin page) inherit the fix.
Closes the last federation matrix ❌ — the GitHubFederatedAuthProvider
implementation already shipped in redb.Identity.Core but had no automated
test (the standard mock-oauth2-server in dev compose is OIDC-only; GitHub
has no discovery, no id_token, no /userinfo — profile + emails come
from REST /user and /user/emails).
FederationProviderConfig.Endpoints (new OAuth2EndpointOverrides
record) repoints the four GitHub URLs per-provider — defaults stay
github.com / api.github.com, but operators can now wire the same
provider against GitHub Enterprise, Gitea, or a self-hosted mock. Public
github.com behaviour is unchanged for configs that don't set the
overrides.
demo_federation_github.ps1 (8 / 8 PASS, ~1.3 s) ships its own mock as
a background pwsh job hosting a System.Net.HttpListener on port 9201:
GET /login/oauth/authorize → 302 with code+state to redirect_uri
POST /login/oauth/access_token → form body, returns access_token JSON
GET /user → Bearer auth, returns id/login/name/email
GET /user/emails → Bearer auth, returns the verified array
e2e: discovery → external-login → mock /authorize → callback → identity
exchanges code → fetches /user → provisions user with mock-supplied
claims. Admin search confirms.
Static mock-github-e2e provider added to context.json (identity.core
- identity.http mirror, marked dev-only with
REMOVE for productionin the//comment).
Wired into run_all.ps1 after demo_federation_link_unlink. Full
suite 44 / 44 PASS. Federation matrix GAP = 0.
OpenIddict's default userinfo error response omits the WWW-Authenticate
header that RFC 6750 §3 marks MUST on protected-resource error responses.
Added HttpIdentityProcessors.AttachBearerChallengeOnError (Http facade,
wired on both GET and POST /connect/userinfo routes) that pulls the
error / error_description from the body and emits
WWW-Authenticate: Bearer realm="identity", error="...", error_description="..."
on every error response. Extended SerializeJsonResponse to forward the
WWW-Authenticate header from In → Out alongside the other
short-circuit headers (Set-Cookie, Location, X-Correlation-Id,
ResponseCode).
demo_userinfo.ps1 (10 / 10 PASS, ~1.2 s) — dedicated RFC-shape probe
the matrix's Bearer → rejected + challenge,
garbage / non-JWT → 401 invalid_token + challenge, tampered JWE
payload → 401 (integrity check fires). Positive claim shape assertions
stay in demo_claim_probes (sub / email / phone_number / address) and
demo_acr_values (sub + voluntary acr), not duplicated.
Wired into run_all.ps1 after demo_jwt; full suite 43 / 43.
Closes the matrix's stale LoginService.ResolveExternalUserCore probed for a
conflict via _redb.UserProvider.GetUserByLoginAsync(ext.Email) — that
only triggered when a local user's login literally equalled the IdP-
supplied email, but self-register validation forbids @ in login so the
arrangement was unreachable via self-service. Effective dead code.
Switched the probe to GetUserByEmailAsync (added to redb.Core in
this batch — see root CHANGELOG for the cross-cutting note). The check
now fires for genuine email overlap, and the federation callback's
HTML error page carries the user-visible
"Federated email already registered locally." description so the
front-end can route to a "log in locally and link your social account"
flow instead of silently provisioning a second account.
demo_federation_link_unlink step 3 reinstated and asserts the
email_conflict signal end-to-end. Full suite 42/42.
Closes the final release-gate item #2. Three coordinated pieces, all shipped in this batch.
redb.Core fix (cross-cutting). UserProviderBase.DeleteUserAsync and
the Users_SoftDelete SQL recipe (both PostgreSQL and MSSql dialects)
were appending _DEL_<timestamp> to _login on every soft-delete. The
PostgreSQL protect_system_users trigger correctly flagged that as
"Cannot change user login" — _login is immutable for ALL users per the
schema contract, and "changing login" is conceptually a delete-and-create
sequence, not an update. Fix: drop _login from the Users_SoftDelete
UPDATE; tombstone via _name only (which IS mutable). Login slot stays
occupied so re-registration with the same login is blocked while the
soft-deleted row exists.
Cascade revoke in admin and self-service DELETE. Both
UserManagementProcessor.Delete and the new MeProfileProcessor.delete
operation now call SessionService.LogoutAsync BEFORE soft-deleting
the user row — that revokes every active session AND every OpenIddict
authorization linked to those sessions (which in turn invalidates any
access/refresh tokens issued through authcode flow). Idempotent — repeat
calls return { success: true, alreadyAbsent: true }.
DisabledUserRejectionHandler. Standard JWT bearer tokens are
self-contained: once issued, deleting the underlying user doesn't
revoke them — the token validates on signature + expiry alone, the
account state is never re-checked. That makes "DELETE /me must
immediately stop authorizing the bearer" impossible without per-request
state. The new handler runs late in the OpenIddict validation pipeline,
extracts the internal redb:user_id claim from AccessTokenPrincipal,
loads the user from the redb store, and rejects with invalid_token
if the user is missing or _enabled=false. Client-credentials tokens
carry no redb:user_id and skip the check.
MeController.HttpDelete forwards to MeProfile/delete with the
caller's id resolved from the access-token subject (same path as
MeProfile/read and update).
demo_me_delete.ps1 (13/13 PASS, ~3.4s): self-register → ROPC →
GET /me → DELETE /me → assert success + sessionsRevoked count →
re-issue same bearer → assert 401 invalid_token (the
DisabledUserRejectionHandler actually fires) → ROPC same creds fails
(user disabled) → admin search no longer surfaces the user (soft-delete
hides from queries) → re-register same login fails (slot occupied,
login immutable) → second DELETE with stale bearer also 401 → cleanup.
Wired into run_all.ps1 after demo_me_profile. Full suite: 41/41.
Release-gate matrix: 4 / 4 closed. Soft-launch for community is honest.
Closes release-gate item #3 from DEMO_COVERAGE_MATRIX.md. The existing
demo_federation probe verified only that /connect/external-login
issued a 302 with an absolute Location — it never followed the redirect
to a real IdP. demo_federation_e2e drives the FULL flow:
- challenge → mock IdP
/authorizePOST (username+claimsform fields the navikt server accepts) → callback URL with code + state → - identity's
FederationCallbackProcessorverifies state, exchanges code, fetches userinfo, applies AutoProvision policy, sets session cookie, 302 toreturnUrl→ - admin-side: search users by email → asserts a fresh user was provisioned with the IdP's email claim →
- replay the entire flow with the same IdP login → asserts user count is unchanged (link-on-replay, no duplicate provision).
Static mock-idp-e2e provider added to context.json in both the
identity.core and identity.http buckets, pointing at
http://127.0.0.1:9199/default where route-mock-oauth2 already
runs in the dev compose stack. The demo skips with a clean 0-exit
message if the mock IdP isn't reachable, so run_all.ps1 stays green
on machines without the dev compose up.
demo_federation_e2e.ps1 is wired into run_all.ps1 immediately
after the existing demo_federation probe.
Known follow-up. Admin DELETE of a federated user triggers the
PostgreSQL protect_system_users trigger ("Cannot change user login")
because the delete path attempts a login mutation downstream. Cascade-
delete semantics for federated users need a separate batch — for now the
demo's cleanup step swallows the 500 and logs a warning. Unlink via
DELETE /me/federated-identities/{id} has the endpoint but isn't
asserted by this demo yet.
Helper note. The demo uses curl.exe for every HTTP hop because
PowerShell's Invoke-RestMethod / Invoke-WebRequest hang against
some identity admin endpoints under suspected HTTP/2-vs-chunked-with-
custom-headers interaction (~15 s timeout) where curl.exe completes
in ~100 ms.
Release-gate matrix progress: 3 / 4 closed (JWKS live-refresh in
batch 12.1, deployment runbook in 12.2, federation e2e here).
Remaining: self-service DELETE /me.
Closes release-gate item #4 from DEMO_COVERAGE_MATRIX.md. Operator-facing
runbook for shipping redb.Identity as an OIDC server in someone else's
stack — covers what's NOT obvious from RedbIdentityOptions XMLdoc alone:
- Pre-flight production checklist — 10 settings (DataProtection.MasterKey, RequireAtRestEncryption, AllowEphemeralKeys, RecoveryCodePepper, EAV signing store, DCR allowlist, encryption, issuer, SMTP, bootstrap-admin lockdown) with dev defaults vs production requirements side by side.
- Secret generation commands for both PowerShell and openssl.
- JWKS rotation runbook — routine cadence (cron sample for 90-day cycle using the admin endpoint shipped in batch 11) and compromise playbook (urgent rotate → retire → bulk-revoke → notify pinned-kid RPs).
- Scale-out invariants — explicit table of what must be true for multi-replica deployment (shared EAV store, shared DataProtection ring, per-replica throttle caveat); explicit list of configs that PROHIBIT multi-replica.
- Healthcheck contract — which probes are readiness vs liveness, why
/connect/tokenis not a healthcheck. - Database setup, migration story, no-downtime upgrade pattern.
- Recommended HTTP defaults — cookie SameSite / Secure / HttpOnly, CORS guidance, payload limits, opt-out for SCIM bulk and dynamic registration if not needed.
- Logging / observability section — which log lines matter for
operators (
JwksRefreshDiagnosticHandler, key-lifecycle events, identity events), plus an honest note that OpenTelemetry meters are not yet wired (P2 backlog). - Backup & restore — what to back up (DB + master key separately), restore order, what's unrecoverable if you lose the master key.
- Pre-launch smoke checks — runnable pwsh script that asserts the most common production misconfigurations (issuer not HTTPS, JWKS empty, DCR allowlist not narrowed, bootstrap endpoint reachable from public).
- Troubleshooting table — typical symptoms keyed to first-thing-to- check and likely root cause.
Release-gate matrix progress (at the time of batch 12.2): 2 / 4 closed
(JWKS live-refresh in batch 12.1, deployment runbook here). Remaining
at that point: self-service DELETE /me and federation real round-trip.
Followup to batch 11 JWKS rotation. Two pieces:
-
DEMO_COVERAGE_MATRIX.mdrelease-gate section. New top-level "🚀 Release gate" table enumerates the 4 items still needed before a honest "not-ashamed" public release: JWKS live-refresh, self-serviceDELETE /me, federation real round-trip, deployment runbook. Test suite (unit/integration) is parked as a separate user-side stream of work. This gives the project a single authoritative checklist for the pre-1.0 push. -
EavSigningKeyStoreOpenIddictPostConfigurerewrite (idempotent + grace-friendly). Three improvements that hold whether PostConfigure runs once at startup or multiple times after a future refresh wiring:- Pulls from
ISigningKeyStore.ListAllIncludingRetiredAsync()(notGetAllAsync()) and filters only onNotBefore <= now, so retired keys stay available for VALIDATION of in-flight tokens signed under them — JWKS still filters by NotAfter independently. - Deduplicates by
kidand usesInsert(0)ordered by IsActive desc so the currently-active key wins OpenIddict's "first algorithm- compatible credential" selection for minting, without disturbing older entries other PostConfigures may have established. - Explicitly syncs
TokenValidationParameters.IssuerSigningKeyswith the rebuiltSigningCredentialslist (OpenIddict 6.x capturesIssuerSigningKeysas a snapshot in its own internal PostConfigure, which runs before ours; without this sync the validation pipeline never sees freshly-added credentials).
- Pulls from
Live-refresh on rotate (the batch-11 caveat — "new tokens use new kid without process restart") fully closed in this batch. The route to the fix went through a diagnostic detour worth recording:
- First attempt re-enabled
IOptionsMonitorCache.TryRemovefromRotateAsync/RetireAsyncand syncedTokenValidationParameters.IssuerSigningKeys. That regressed 6 self-service/medemos with "signing key associated to the specified token was not found". - Adding a class-based
JwksRefreshDiagnosticHandlerthat fires on every rejected validation and dumpstoken.kid,server.SigningCredentials,validation.IssuerSigningKeys, and the delta exposed the actual root cause: myPostConfigurewas including retired keys inSigningCredentials, so OpenIddict (which picks the first algorithm-compatible credential for minting) sometimes signed tokens under a retired kid that theLiveJwksProcessorhad already filtered out of JWKS — RPs validated the kid against the live JWKS, missed it, rejected. - Real fix: split the key pool.
SigningCredentialsgets only in-window keys (NotBefore <= now < NotAfter) so the mint path can never pick a retired kid.TokenValidationParameters.IssuerSigningKeysandTokenDecryptionKeysget the full validation pool (including retired past NotBefore) so in-flight tokens signed under previously-active- then-retired kids still validate during the grace window. - Cache invalidation also clears
IOptionsMonitorCache<OpenIddictValidationOptions>(not just server), so the validation pipeline'sTokenValidationParameterssnapshot refreshes alongside.
demo_jwks_rotation extended back to 15 steps; new probes 8b and 11
mint ROPC tokens after rotate / retire and assert id_token.kid matches
the freshly-rotated kid. Full suite 39/39. JwksRefreshDiagnosticHandler
kept in the pipeline — silent unless validation rejects, in which case
it logs everything needed to pinpoint the next failure.
Closes the long-standing 🟦 JWKS rotation / key rollover backlog item
from DEMO_COVERAGE_MATRIX.md. Two coordinated changes:
- Admin signing-key lifecycle. New
ISigningKeyStoresurface methods (ListAllIncludingRetiredAsync/RotateAsync/RetireAsync) plusSigningKeysManagementProcessormounted atdirect-vm://identity-manage-signing-keysand surfaced over HTTP at[Route("signing-keys")]:GET /api/v1/identity/signing-keys— list every key row (active + demoted + retired) for the audit trail, never leaks private material.POST /api/v1/identity/signing-keys/rotate— mints a fresh active key of the requested kind (signingby default), demotes previously-active rows of the same kind (IsActive=false) but preserves theirNotAfterwindow so old tokens keep validating during the grace period.DELETE /api/v1/identity/signing-keys/{kid}— ends the validity window immediately (NotAfter=now) so the key drops out of the live JWKS on the next request and tokens signed under it stop validating. All three operations emit audit events (SigningKeyRotated,SigningKeyRetired) throughWireTap(IdentityEndpoints.Events). Gated byidentity:applications.manageon the granular-scope guard.
- Live JWKS endpoint. New
LiveJwksProcessorreads keys directly fromISigningKeyStore.GetAllAsync()on every JWKS request, replacing the default staticJwksEndpointProcessorthat goes through OpenIddict's frozenSigningCredentialslist. Wired inIdentityCoreRouteBuilderconditionally onUseEavSigningKeyStore=trueso RPs see the new kid immediately after admin rotate/retire, without any process restart. Public JWK conversion strips the private RSA components (d,p,q,dp,dq,qi) defensively before serialisation.Cache-Control: public, max-age=3600retained (Microsoft / Google / Auth0 convention; well within typical 24-72 h rotation grace).
demo_jwks_rotation.ps1 (13 / 13 PASS, 3 s total): DCR
identity:applications.manage → list → ROPC under K1 → rotate → JWKS now
contains BOTH K1 and K2 → old id_token still verifies against live JWKS →
admin list shows K1 demoted but inJwks=true and K2 active → retire K1 →
JWKS now contains only K2 → old id_token FAILS verification (kid absent)
→ admin list still carries K1 with inJwks=false (audit trail) → cleanup.
Documented caveat. OpenIddict caches SigningCredentials at process
start; new tokens continue using the old kid until the OpenIddict cache
refreshes (process restart or future IOptionsMonitor wiring — a separate
batch). The contract this batch locks in is the JWKS-observability slice:
rotate / retire are visible to RPs immediately and old tokens validate
through the rotation grace window. Captured in context.json under
UseEavSigningKeyStore comments.
Dev config flips (redb.Identity/context.json):
UseEavSigningKeyStore=true, AllowEphemeralKeys=false,
DataProtection.RequireAtRestEncryption=false, fixed dev
RecoveryCodePepper (since the EAV store path bypasses ephemeral-pepper
generation). All three are dev-only — production must set a real
DataProtection MasterKey/Certificate and a secret pepper.
DynamicRegistrationAllowedScopes extended with
identity:applications.manage so the demo can DCR an admin client without
an initial-access-token bootstrap.
A systematic sweep for the disease patterns surfaced in batches 8–9 (the
30 s RecordPasswordHistory deadlock and the silent throttle wait). Two
deliverables:
doc/PERF_RULES.mdcodifies the six rules learned from those bugs so future contributors do not re-introduce them: don'tCreateScopeinsideWithRedbTx, opt into.RejectOnOverflow()on HTTP-facing throttles, inspect per-step demo timings (not just totals), don't re-resolve scoped services that the exchange already carries, pass the in-flightIRedbServiceto helpers, and add in-tx overloads on singleton stores that own a_scopeFactory. Each rule lists the measured speedup it has already delivered upstream.UserManagementProcessor.List+Searchno longer fire oneQuery<UserProps>()per user — the newLoadOidcPropsBatchAsynchelper collapses the OIDC-props lookup into a singleWhereRedb(o => keys.Contains(o.Key))round-trip. SCIM users had this batching already; the admin path now mirrors it. The fix has near-zero impact at demo scale (one-user pages) but turns anO(users) × RTTcliff intoO(1) RTTunder production list sizes.
The audit also cross-checked the other singleton Redb*Stores for the
disease #1 pattern (PasswordReset, EmailVerification, ChangeEmail,
DpopReplay, OtpStore, WebAuthn, SigningKey, ClientOriginRegistry,
XmlRepository) and confirmed each one either writes to a disjoint table
set, is not called from a WithRedbTx-wrapped processor, or opens its
own nested transaction — none of them reproduce the parent-FK contention
that broke RecordPasswordHistory. They stay as-is.
No regressions: run_all 38/38 PASS in ~114 s (the 38th demo is the new
demo_throttle_rfc6585; ~117 s in batch 9 → 114 s here is within noise).
/connect/token, /connect/par, and /connect/register now opt into the
redb.Route framework's new .RejectOnOverflow() throttle mode (added in
redb.Route 3.1.1). Previously the throttle wrapper silently
SemaphoreSlim.WaitAsync-blocked overflow exchanges for the remainder of
the rate-limit period — a 6th rapid /connect/register from the same IP
would synchronously hang up to ~10 seconds before being processed, which
to the client (and to liveness probes, and to upstream proxies) looked
exactly like a hung server.
With opt-in enabled, overflow exchanges now short-circuit immediately
with HTTP 429 + a Retry-After delta-seconds header (RFC 7231
§7.1.3) and a structured JSON body
({error: "rate_limit_exceeded", retry_after: N}) so a well-behaved
client can back off explicitly. Per-key isolation (each client_id on
/token + /par, each remote IP on /register) is preserved — one client
hammering the endpoint does not throttle another.
A new probe demo_throttle_rfc6585.ps1 exercises the contract end-to-end:
a parallel burst of 30 token requests under the same client_id produces
~10 successes (the bucket size) and ~20 × 429, each carrying the header
and a matching body, then after Retry-After + 0.5 s the same client
recovers to 200 and a second (untouched) client succeeds immediately.
Existing demos run unchanged — the dev context.json raises the DCR
budget to 100/s so the suite's many back-to-back DCRs from loopback don't
artificially trip the limit (the in-code production default of 5 per 10 s
stays untouched).
redb.Identity.Core — RFC compliance mop-up: sessions admin, SCIM Bulk + ETag, acr_values + a 150× perf fix
Four new probe demos close the last remaining advertised-surface gaps in the coverage matrix, with one serious server-side perf fix shipped alongside.
-
demo_sessions_admin.ps1(14/14) — exercises theidentity:sessions.managescope axis end-to-end againstSessionsController. Spawns three sessions via the cookie/loginflow (ROPC does not create_sessionsrows — that's LoginProcessor's responsibility), then probes the admin GET / DELETE / DELETE-all paths including thedryRun=truemode. Cross-checks thatidentity:readcovers GET on/sessions(read-only universal) while mutations on the same path are rejected 403. -
demo_scim_bulk.ps1(9/9) — RFC 7644 §3.7 bulk endpoint. Probes POST × 3 (all 201, bulkId/location echo), mixed POST + DELETE, thefailOnErrors=1early-stop branch (op[2] not executed once the error cap is hit), and thefailOnErrors=0continue-on-error branch (op[2] still runs). The wrong-outer-schema probe is flagged as a §3.7.1 lenient deviation rather than a probe failure — server-side strict-schema enforcement is a compliance polish, not a security gap. -
demo_scim_etag.ps1(10/10) — RFC 7644 §3.14 ETag concurrency. POST emits a weak ETag (W/"<hash>"); PUT with the matchingIf-Matchsucceeds and rotates the ETag; PUT with a staleIf-Matchis rejected 412 Precondition Failed; PUT withoutIf-Matchsucceeds (server uses first-writer-wins semantics by default). The GET-echo step is lenient because the redb.RouteHttpControllerDispatcherswapsOutafter the controller returns, droppingscim.ETagbeforeMapScimResponseToHttpStatusruns — flagged as a polish task; concurrency control still works via the POST/PUT-returned ETag chain. -
demo_acr_values.ps1(8/8) — OIDC Core §2acrclaim. The principal builder now emitsacron every id_token with a value derived from the actual authentication strength:"1"for single-factor (password, ROPC, cookie sessions),"2"whenSessionProps.MfaVerified=true. The demo asserts the OIDC §5.5.1.1 voluntary-claim contract: a request foracr_values=2against a non-MFA session does NOT reject and does NOT upgrade — the server returns its actual strength and the RP enforces.
Server-side changes:
-
Critical perf fix.
IdentityProcessorHelpers.RecordPasswordHistoryAsyncandUpdatePasswordChangedAtAsyncused to take the singletonEavPasswordHistoryStorepath which opensscopeFactory.CreateAsyncScope()per call. UnderWithRedbTx, the fresh scope acquired an independent Npgsql connection that immediately blocked on the open outer transaction; the wait resolved via the 30 sTransactionPolicy.Timeout. Every adminPOST /usersand SCIMPOST /scim/v2/Userstook ~30 s/op for this reason — unnoticed in earlier batches because no demo created users through those endpoints (account-register has a separate flow that bypasses the helper). The fix adds a newRecordPasswordHistoryAsync(exchange, context, redb, …)overload threaded through every in-route caller (admin UserManagementProcessor, ScimUserProcessor create + replace, MePasswordProcessor, PasswordResetProcessor); when anIRedbServiceis supplied we reuse it (already enlisted in the outer tx) instead of opening a fresh scope. Measured impact: 30 183 ms → 200 ms (~150× faster). -
ScimUserProcessor.Createnow callsSetETagHeaderon the 201 response so RFC 7644 §3.14 versioning works on the initial write (previously only the GET/PUT paths emitted it).SetETagHeaderitself was widened to set bothIn.HeadersandOut.Headerskeys forscim.ETagAND a directOut.Headers["ETag"], so the header survives the various Out-rewriting paths through redb.Route's pipeline. -
DCR
DynamicRegistrationAllowedScopesin the shipped devcontext.jsonis widened to includeidentity:sessions.manageandscimso the new demos can DCR clients directly. Same PERMISSIVE-dev caveat as the prior batch — production hosts MUST setDynamicRegistrationInitialAccessTokenor trim the list. Masteridentity:manage,identity:applications.manageandidentity:impersonateremain excluded by default.
redb.Identity.Core — Admin-bootstrapped P1 batch: groups/roles claims, granular admin scopes, prompt= variants + max_age
Three new probe demos that close the last P1 GAPs in the coverage matrix and the OIDC §3.1.2.1 auth-parameter family:
-
demo_groups_roles_claims.ps1(16/16) — full admin-bootstrapped flow. Mints a client_credentials admin token withidentity:users.manage, creates a "developers" team under an "engineering" organisation group (tree hierarchy), assigns the test user with a per-membershipRole, then asserts the user's id_token + userinfo carry: •groupsclaim with BOTH the direct group AND the ancestor (provingGroupClaimsResolver.EnrichPrincipalAsync's tree-walking), •role(s)claim with the membership label, • role rotation propagates (senior → lead) on next ROPC, • membership removal clears bothgroupsandroleclaims. -
demo_admin_scopes.ps1(17/17) — two complementary scope axes: •identity:readcross-path GET (users / groups / applications / audit all readable), mutations rejected 403insufficient_scope, •identity:audit.readgranular single-path: only /audit GET passes, /users + /applications GET → 403 with the exact scope name in the error_description (requires one of: identity:users.manage/identity:applications.manage), • unauth and garbage-bearer → 401 with the documented WWW-Authenticate header. This is the first demo to exerciseGranularScopeGuardProcessordirectly with both axes. -
demo_prompt_max_age.ps1(9/9) — OIDC §3.1.2.1 auth-request parameters. Cookie-session + POST /connect/authorize pattern, no browser: •prompt=login→error=login_requiredon the redirect_uri, •prompt=consent→ code on implicit-consent client (would beconsent_requiredon Explicit), •prompt=select_account→ spec-tolerant (code OR login_required/account_selection_required/interaction_required), •max_age=0→login_required(fresh re-auth required), •max_age=99999→ code returned (still within window).
Supporting server fixes uncovered by demo_prompt_max_age:
-
HandleAuthorizationRequestHandlernow enforces OIDC §3.1.2.1max_age. The typedcontext.Request.MaxAgeis read first; when null we fall back to the rawmax_ageparameter string because OpenIddict normalisesmax_age=0to null (treats it as "no constraint") despite the spec explicitly requiring re-auth in that case. Comparison uses>=so the boundary case fires deterministically without depending on sub-second clock drift. -
AttachSessionPrincipalHandlernow overwrites theauth_timeclaim thatIdentityPrincipalBuilder.Buildstamps at construction (= "now") with the actual session creation timestamp fromSessionProps.DateCreate(RedbObject base column).auth_timeMUST reflect the original authentication, not the moment the principal is rebuilt from the session cookie — without this, max_age and any other staleness check would see 0 elapsed seconds on every rebuild and silently pass. -
Default
DynamicRegistrationAllowedScopesin the shipped devcontext.jsonis widened to includeidentity:users.manageandidentity:audit.readso the demo suite can probe admin-only paths. This is PERMISSIVE: any caller ofPOST /connect/registercan mint aclient_credentialstoken carrying these scopes. Production hosts MUST either setDynamicRegistrationInitialAccessTokenso DCR requires an out-of-band bearer, or trim the list back to user-info scopes. Masteridentity:manage,identity:sessions.manage,identity:applications.manageandidentity:impersonateremain excluded by default — those carry the highest blast radius.
redb.Identity.Core — Self-service P2 batch: password-change negatives, full forgot/reset, MFA disable/replace
Three new probe demos and a cluster of supporting server fixes:
-
demo_password_change_negatives.ps1(13/13) exhaustively probes the/me/passwordPUT contract: wrong oldPassword (400invalid_password), the four PasswordPolicy composition rules (MinLength=12, RequireDigit/Upper/Lower), HistoryCount=5 reuse (the policy validator's history store), and the unauthenticated rejection (401). The positive path stays indemo_me_profile. -
demo_password_reset.ps1(11/11) walks the full anonymous recovery flow: DCR withpassword_reset_uris(new RFC 7591 §2 metadata extension), the anti-enumeration contract for unknown email and non-whitelistedcallerResetUrl(both must return 200 + no mail), the GreenMail SMTP intercept and MIME quoted-printable parse for the actualjti+token, the bogus-token rejection (HTTP 400 with the documented genericinvalid_token), the happy reset, and the single-use replay rejection. The probe asserts that bogus tokens do NOT consume the jti slot so a legitimate user with a fat-fingered token isn't locked out of their reset link. -
demo_mfa_disable_replace.ps1(13/13) covers the disable/replace flow thatdemo_mfa_totpdoesn't reach: enrol →DELETE /me/mfa/totp→ confirm disabled → re-enrol → assertsecret_base32is a fresh value (S2 != S1), so a disabled-then-re-enrolled MFA is NOT a stale cache hit. Also asserts theDELETErequires a bearer (401 unauth).
Supporting server changes:
-
MePasswordProcessornow catchesUnauthorizedAccessExceptionfromUserProvider.ChangePasswordAsyncand surfaces it as the documented 400invalid_passwordrejection. Previously the core provider raised for wrong-old-password while the processor expected a bool, so the exception escaped and/me/passwordreturned 500. -
IdentityProcessorHelpers.ValidatePasswordPolicyAsyncnow resolvesIPasswordPolicyValidatorfrom the Identity child SP viaIRouteContext.GetIdentityServiceOrDefault<T>(exchange)(the bridge that mirrorsGetIdentityService). Under .tpkg deployment the per-exchange SP is a scope of the HOST root, not the child, so the scoped validator silently missed andValidatePasswordfell back to the legacy length-only check — composition rules (digit/upper/lower) and history were never enforced under .tpkg loading, despite being declared in PasswordPolicy. Now end-to-end-honoured under both Path 1 (single-SP test fixture) and Path 2 (tpkg). -
IdentityModuleHost.Buildnow bridges the host'sIRouteContextinto the Identity child SP. Without itSmtpEmailNotificationChannel(which depends onIRouteContextto publish todirect-vm://identity-email-send) couldn't be activated and the ValidateOnBuild step tore down the whole module at startup the momentSmtp.Enabled=truewas set in configuration. -
IdentityCoreRouteBuilder.csSMTP route now calls.Disconnect()on the redb.Route.Mail builder so the producer closes the TCP connection after each transactional mail. Transactional Identity mail is sparse (forgot-password, verify-email), and pooled SMTP connections routinely fail against GreenMail (SO_TIMEOUT=30s) and real relays alike —Service shutting down and closing transmission channelcost a full demo run mid-flight before this change. -
RFC 7591 §2
client_metadataextension:password_reset_uris,email_verify_uris,change_email_urisare now accepted byDynamicRegistrationProcessor, persisted toApplicationProps, and echoed back in the registration response. The adminPUT /applications/{id}endpoint accepts the same fields via the matchingUpdateApplicationRequest/ApplicationResponseadditions. Without these, the per-client landing-URL whitelists enforced by the server-side recovery flow were unreachable from any external caller — the only path to set them was a direct DB write through the seed pipeline. Default behaviour (whitelistnull→ flow disabled) is preserved; existing clients are unaffected.
Default Smtp configuration in the shipped context.json now points
at the standard dev GreenMail container (route-greenmail, SMTP 3025
/ REST API 8080). Production hosts must replace Host/Port with a real
relay and set Security=StartTls or Ssl with valid credentials.
The principal builder now emits the OIDC-mandated auth_time claim on every
sign-in (NumericDate per RFC 7519 §2, encoded with ClaimValueTypes.Integer64
so OpenIddict's ValidateSignInDemand accepts it as a typed Int64 instead of
rejecting a string-valued claim as malformed). The claim travels to both the
access token and the id_token. RPs that mandate auth_time (max-age handling,
step-up auth, prompt=login) now have the value they need.
The default DynamicRegistrationAllowedScopes allowlist is widened to include
the standard OIDC user-info scopes (phone, address, groups, roles).
These describe the user, not API privilege, and are already advertised in
scopes_supported — keeping them out of the DCR allowlist while advertising
them globally was an internal inconsistency. Admin scopes (identity:*, scim)
remain excluded by default.
A new probe demo demo_claim_probes.ps1 walks a self-service auth-code+PKCE
flow with scope=openid profile email phone address offline_access and
asserts the resulting id_token claim shapes against the relevant specs:
sub/iss/aud/exp/iat (RFC 7519 baseline), auth_time (skew ≤ 5 min),
amr contains pwd (RFC 8176), nonce round-trips verbatim,
azp matches client_id when present (OIDC §2 — optional for single-aud),
email/email_verified, phone_number/phone_number_verified,
address as a JSON object with formatted (OIDC §5.1.1), and a custom
dept claim. The same surface is re-asserted via /connect/userinfo.
Individual client registrations can now opt into mandatory PAR even when
the deployment does not require PAR globally. Dynamic Client Registration
(RFC 7591) accepts require_pushed_authorization_requests=true on the
client metadata; the flag is persisted on ApplicationProps and echoed
back in the registration response. When such a client makes a direct
GET /connect/authorize request without a request_uri issued by
/connect/par, the authorization-request handler rejects it with
error=invalid_request and an error_description pointing the developer
at /connect/par. The error is delivered through the validated
redirect_uri per RFC 6749 §4.1.2.1 (with state round-tripped), so the
client's existing OAuth error handling sees a normal, contract-compatible
error redirect — not a JSON 400 or a /login UI bounce.
Discovery now advertises the PAR endpoint URL itself
(pushed_authorization_request_endpoint) — OpenIddict's built-in
discovery handler emits the *_auth_methods_supported list for PAR but
not the URL. The companion require_pushed_authorization_requests
boolean is emitted only when the global enforcement is on; per-client
enforcement is a property of individual registrations and is not
advertised at the AS level. demo_par_per_client.ps1 covers the full
contract: discovery exposes the URL → DCR with the flag echoes it back →
direct /authorize is rejected with invalid_request via redirect_uri
- state →
/parpush +/authorize?request_uri=…is accepted (now falls through to the normallogin_requiredredirect).
Why it matters. PAR (RFC 9126) eliminates the classic OAuth
side-channels in the authorization request — the URL no longer carries
the request parameters past intermediaries (proxies, browser history,
referer headers, server logs, browser extensions), and the AS validates
the request before any redirect. FAPI 2.0 / financial-grade profiles
require it; high-assurance clients in mixed deployments need to enforce
it without forcing the global flag on every other client. Per-client
opt-in unblocks that pattern. The fix also closes a subtle integrity
gap: previously a client could advertise that it requires PAR (e.g. in
its own metadata), but the AS had no way to actually enforce it on a
per-client basis — so a misconfigured RP could quietly fall back to
plain /authorize without anything noticing.
Confidential clients can now authenticate to /connect/token and
/connect/introspect with a signed JWT-bearer assertion instead of a
shared client_secret. Dynamic Client Registration (RFC 7591) accepts
token_endpoint_auth_method=private_key_jwt together with an inline
jwks (JSON Web Key Set carrying the client's public keys). The JWKS is
persisted via the redb-backed application store and consulted by
OpenIddict's built-in client-assertion validator at request time —
verifying signature, iss=sub=client_id, aud=<endpoint>, and exp.
Discovery now advertises token_endpoint_auth_signing_alg_values_supported,
introspection_endpoint_auth_signing_alg_values_supported, and
revocation_endpoint_auth_signing_alg_values_supported per RFC 8414 §2,
listing the RSA / RSA-PSS / ECDSA algorithms accepted on the JWT-bearer
assertion. demo_private_key_jwt.ps1 exercises DCR registration with
jwks → client_credentials token issuance under JWT-bearer auth →
introspection under JWT-bearer auth → tampered-signature negative path
(401 invalid_client); demo_discovery_shape.ps1 now asserts that any
endpoint advertising private_key_jwt also advertises a matching
signing-alg list.
Why it matters. Shared client_secret is the weakest link in the
backchannel: a single leak (CI logs, repo, container snapshot) hands an
attacker the full client identity. With private_key_jwt the client only
ever exposes its public key on the wire — the private key never leaves
the client process — and short-lived (≤60s) audience-bound assertions
prevent replay across endpoints. This is the auth method that high-trust
deployments (regulated workloads, FAPI-style profiles, federated SSO
backends) require, and it brings the redb.Identity surface in line with
what discovery has been advertising on five endpoints since day one.
The public sub value emitted in ID tokens, access tokens, userinfo, and
introspection responses is now a Guid persisted on the user object's
value_guid slot. The GUID is generated once on user creation (registration,
admin bootstrap, federation provisioning, SCIM provisioning, dynamic client
registration's bootstrap admin path) and never changes for the lifetime of
the user — even if the row is renamed, re-keyed, or migrated across schemas.
Why it matters. Before this preview, sub was the bigint
RedbObject._id, which leaks insertion order, is reusable if a user is
hard-deleted, and changes shape across REDB instances. The GUID is opaque,
collision-free, portable across deployments, and decouples the externally
observable identity from the internal storage primary key. This is the
shape that downstream resource servers, third-party SDKs, and federated
RP relying parties expect, and it brings redb.Identity in line with the
OIDC Core 1.0 §5.7 guidance that sub should be a stable, locally-unique,
non-reassigned identifier within the issuer.
Dual-emission for in-process consumers. Every principal built by
IdentityPrincipalBuilder, AttachSessionPrincipalHandler, the password
flow, the authorization-code flow, the device flow, the verification flow,
and the federation flow now carries two identity claims:
| Claim | Type | Audience | Source |
|---|---|---|---|
sub |
string (GUID, lower-case, no braces) |
Public — wire-visible to RPs and resource servers | UserProps.value_guid |
redb:user_id |
string (bigint) |
Internal — never leaves the issuer; used by in-process modules that key off _users._id |
RedbObject._id |
The internal claim is destination-restricted to the access token only and
is stripped from id_token, userinfo, and introspect responses by the
existing OpenIddict claim destinations machinery. Modules in the same
worker that resolve the user via direct-vm://identity-userinfo continue
to receive both claims.
Store-side changes. Subject linkage on the four OpenIddict store rows
moved from RedbObject.Key (bigint) to RedbObject.value_guid (Guid):
AuthorizationProps—value_guidcarries the public sub GUID. TheIOpenIddictAuthorizationManager.FindBySubjectAsynclookup goes throughQuery<AuthorizationProps>().WhereRedb(o => o.ValueGuid == subjectGuid), which is a single indexed scan on the REDB primary key column.TokenProps— same.FindBySubjectAsync,RevokeTokensBySubjectAsync, and the chained-revoke pass on authorization-code reuse all key offvalue_guidnow.ApplicationPropsandScopeProps— unaffected on the subject axis, but theirSubject-bearing referenced rows were swept consistently.
/me/* and management. Every self-service and admin route that took a
subject path parameter (/me/profile, /me/sessions, /me/mfa,
/me/webauthn, /me/consents, /me/federated, the SCIM endpoints, and
the admin user/token/authorization management) now treats the path as the
GUID sub. Internal lookups translate the GUID back to the bigint via a
single WhereRedb(o => o.ValueGuid == subjectGuid) query before issuing
any storage operation. The RequireSelfOrAdminProcessor IDOR guard
compares the GUID claim from the access token against the GUID in the
path — a bigint cannot be substituted to escalate.
Migration path. This is a breaking change only for callers that
compared sub against _users._id directly. In-process callers should
switch to the redb:user_id claim; external callers should treat sub
as opaque (which is the OIDC requirement). No data migration is required:
the value_guid slot is filled lazily on the first claim-issuing event
for each existing user, and both sub and redb:user_id are emitted
even before the lazy fill completes.
The full OpenIddict server pipeline runs on IExchange instead of
HttpContext. There is no ASP.NET dependency in the Core package —
not even an indirect one. Every endpoint a relying party touches
(/connect/authorize, /connect/token, /connect/userinfo,
/connect/introspect, /connect/revoke, /.well-known/jwks,
/.well-known/openid-configuration, /connect/par, /connect/device,
/connect/verify, /connect/register, /connect/logout,
/connect/checksession, /connect/endsession) is registered as a
direct-vm://identity-* route in Core, and the HTTP-facing path is the
HTTP facade rebinding into that route.
The full set of standards covered by the engine in this preview:
- OIDC Core 1.0 —
authorize,token,userinfo, ID Token issuance with all standard claim destinations,prompt=none,max_age,id_token_hint,claimsrequest parameter, request objects (signed JWT request URIs). - OAuth 2.1 / RFC 6749 — authorization code (with PKCE required for public clients), client credentials, refresh token (with rotation and reuse detection), resource owner password (gated, off by default).
- RFC 6750 — Bearer token usage,
WWW-Authenticateshapes for the three error classes. - RFC 7591 / RFC 7592 — Dynamic Client Registration (DCR) and management — soft-deleted client recovery, mTLS / private_key_jwt / client_secret_basic / client_secret_post / none auth methods, registration access tokens, configurable bootstrap-admin gate.
- RFC 7662 — Token introspection with both JWT and reference token resolvers. Active state is computed from the redb-backed status field with explicit revocation precedence.
- RFC 8414 — Authorization-server metadata document, JWKS endpoint.
- RFC 8628 — Device authorization grant —
device_code,user_code, polling endpoint, verification endpoint, slow-down back-off. - RFC 8693 — Token exchange (subject token + actor token, scope narrowing).
- RFC 9126 — Pushed Authorization Requests (PAR) —
request_urione-time redemption with TTL guard. - RFC 9449 — DPoP — proof validation on
/connect/tokenand on the resource server side via theredb.Identity.Resource.Dpoppackage; jti replay cache backed by redb. - OIDC Backchannel Logout 1.0 + RFC 8417 SET — backchannel logout
notification (signed
logout_token) and the cluster-friendly revoked-SID broadcast surface (/revoked-sids/add,/revoked-sids/since) for replicas to converge without sticky sessions.
Every OpenIddict abstract store has a redb implementation backed by a
typed *Props object on a named REDB instance (default name: "identity"):
RedbApplicationStore—ApplicationProps(client metadata, redirect URIs, post-logout URIs, secret hashes, JWKS URIs, settings JSON).RedbAuthorizationStore—AuthorizationProps(subject GUID, application id, type, status, scopes, properties).RedbTokenStore—TokenProps(subject GUID, type, status, authorization id, payload — opaque or self-contained, expiration, reference id hash). Atomic single-use semantics onauthorization_code/device_code/refresh_tokenredemption are provided by optimistic-concurrency onRedbObject.hashinsideUpdateAsync— exactly one concurrent redeem winner per code is guaranteed without an explicit lock.RedbScopeStore—ScopeProps(resources, descriptions, properties).
All four stores key off REDB primary fields (value_guid, value_string,
value_long) for indexed lookups; nothing falls back to in-memory
filtering at the application layer.
A first-class admin surface for OIDC claim shaping:
- Claim mappers — pluggable rules that map source attributes (REDB user props, federation IdP claims, group memberships, custom JSON paths) to outgoing OIDC claims with destination scoping.
- Claim scopes — declarative association of claim mappers with one
or more scopes; scope is granted only if the requesting client is
permitted (
RestrictScopeByGroupMembershipHandler). - Federation providers — OIDC and GitHub external IdPs persisted as
redb props rows, with admin CRUD over
direct-vm://identity-federation-*and lazy user provisioning on first successful federated login.
- Session principal store —
AttachSessionPrincipalHandlerresolves the user's session from the auth cookie / opaque session id, attaches a fully-built principal to the OpenIddict transaction, and is the single source of truth forprompt=nonedecisions. - MFA — TOTP (RFC 6238), SMS/Email OTP, WebAuthn (FIDO2 L2), and
recovery codes; enrolment, verification, and removal flows surface
through
/me/mfaand/me/webauthn. MFA replay protection on the token endpoint is enforced via the same redb-backedjti/nonce cache used for DPoP. - Account self-service — registration (with optional admin approval), e-mail verification, change-email confirm, password recovery, profile edit, session list / revoke individual / revoke all.
- Native consent — content-negotiated
/connect/authorizereturns a machine-readable400 consent_requiredJSON when the client sends theX-Identity-Delegate-Consent: 1header (used byBackchannelOidcClient.RecordConsentGrantAsyncfor native consent flows where there is no browser to render the host's HTML page).
InitRoute registers the engine on a named REDB instance
(default "identity") so Identity state lives in its own props
namespace — fully isolated from other modules in the same Tsak worker.
Configuration is bound from context.json and exposes the OpenIddict
options (signing keys / encryption keys / token lifetimes / endpoints
toggle / DPoP requirement) as plain JSON.
Exposes every public OIDC endpoint over HTTP, plus the management
surface and the /me/* self-service surface, by binding HTTP routes to
the corresponding direct-vm://identity-* route. The facade is
transport-only — no Identity logic lives here, so removing the
package and using a different transport facade (gRPC, RabbitMQ,
SignalR) leaves all behaviour intact.
The browser-touching pages required by the Authorization Code + Device Code flows:
/login,/logout/consent(the HTML form thatBackchannelOidcClientand human-driven flows both POST to)/account/register,/account/verify-email,/account/recover,/account/change-email-confirm/me/*SPA-style pages backed by the/me/*JSON endpoints
All pages are plain Razor templates wired through redb.Route's HTML view rendering — no MVC, no controllers.
Two layered surfaces:
- In-process — strongly-typed wrappers over
direct-vm://identity-*routes for callers in the same Tsak worker. Zero serialization, zero loopback, fully synchronous from the caller's perspective. - HTTP —
BackchannelOidcClient(PKCE, native consent, refresh rotation) for relying parties that talk to a remote redb.Identity deployment.
A Microsoft.AspNetCore.DataProtection setup that runs without
ASP.NET — keys are persisted as redb props rows, signing and encryption
keys rotate on a schedule, and the key ring is shared across all
replicas of a Tsak cluster automatically because every replica reads
the same REDB instance.
Directory-driven user sync handler (LdapSyncHandler) plus a
bind-on-login adapter that allows AD users to authenticate without
mirroring credentials into the redb user store. Group membership is
mapped to claim mappers, so AD groups can drive scope grants without
touching the Identity admin surface.
A drop-in middleware for redb.Route resource pipelines that validates
incoming DPoP proofs against the access token's cnf.jkt, with a redb-
backed jti replay cache. Tied to the issuer's nonce policy via the
shared key ring.
OpenIddict's built-in handlers call IOpenIddictApplicationManager.FindByClientIdAsync
8-11 times per /connect/token request for the same client_id — each call
previously hit a fresh REDB props lookup (~8 ms each on the PVT join). Our own
code only resolves the application from 5 cold-path locations, so the duplication
is entirely inside the OpenIddict pipeline.
RedbApplicationStore now keeps a per-instance Dictionary<string, RedbObject<ApplicationProps>?>
keyed on client_id. Because the store is registered as Scoped (one instance per
HTTP request), the cache lives only for the lifetime of one OIDC pipeline invocation —
no TTL, no cross-request invalidation, no global state. null is cached too so repeated
lookups for unknown client_id values don't re-hit the database. CreateAsync,
UpdateAsync, and DeleteAsync keep the cache coherent for the rare case the same
request creates and then re-resolves a client.
Net effect on a steady-state /connect/token request: one DB round-trip for the
application row plus 7-10 cache hits, saving ~80 ms per token issuance.
The same Scoped-store / per-request cache pattern that paid off so well on
RedbApplicationStore has been extended to the two other hot OpenIddict
stores. [STORE-DIAG] instrumentation on a /connect/token refresh-rotation
flow showed IOpenIddictTokenStore.FindByIdAsync being called twice for the
same token id (~8 ms per PVT join) and the matching authorization being
resolved at least once during issuance — duplicated work that the request-scoped
cache erases for free.
RedbTokenStore now keeps two coordinated dictionaries:
| Cache | Key | Filled by |
|---|---|---|
_idCache |
bigint object id | CreateAsync, FindByIdAsync MISS, FindByReferenceIdAsync MISS |
_refCache |
reference id (hash of refresh-token / authorization-code value) | CreateAsync, FindByReferenceIdAsync MISS, FindByIdAsync MISS when value_string is set |
Both caches are populated on each MISS so a subsequent lookup by either key
returns the same in-memory instance, and DeleteAsync removes from both to
keep the dictionaries coherent inside one request. null is cached too so
unknown ids/reference values cannot re-hit the database.
RedbAuthorizationStore adds a single _idCache keyed on bigint object id.
Authorization rows are resolved repeatedly during refresh-token rotation and
authorization-code redemption — the cache spares those redundant PVT joins.
FindByApplicationIdAsync, FindBySubjectAsync, and FindAsync are not
cached: they return enumerables and the result sets are not stable enough
across the few moments inside one request to justify the bookkeeping.
Measured on the acceptance demo suite (warm-start, local PostgreSQL, BCrypt cost factor unchanged at 12):
| Demo | Before | After |
|---|---|---|
password_ropc |
4750 ms | 3790 ms |
refresh_rotation |
~4000 ms | 3596 ms |
me_sessions |
~4100 ms | 3576 ms |
The 19-demo full run completes in 40.4 s with all flows green. Worker logs
confirm the documented Tok.FindById call#1 MISS … call#2 HIT pattern, and
the existing App.FindByClientId HIT counters continue to fire 7-10 times
per token issuance — i.e. the new caches stack with the older one rather
than replacing it.
The diagnostic [STORE-DIAG] … logs ride along at LogInformation level
for one preview iteration so production deployments can validate cache hit
ratios on real traffic; they will be downgraded to LogTrace (or removed)
in the next preview once the win is confirmed in the wild.
HandleTokenRequestHandler.HandleAsync for the password grant previously
called IUserProfileService.BuildPrincipalAsync(userId, scopes), which
re-loaded the IRedbUser entity and the UserProps envelope from REDB
even though LoginService.AuthenticateAsync had just loaded both during
credential verification a few microseconds earlier. The handler now
forwards the already-loaded User, OidcProps, and SubjectGuid from
the LoginResult directly to the BuildPrincipalAsync(user, props, sub, scopes)
overload when they are present, falling back to the by-id load only for
legacy users whose value_guid slot has not been backfilled yet.
DynamicRegistrationProcessor previously called
manager.CreateAsync(descriptor) (write #1), then
manager.FindByClientIdAsync(clientId) (read), then mutated
Props.RegistrationAccessTokenHash and called redb.SaveAsync(app)
(write #2). The whole point of the second write was to attach a single
hashed RFC 7592 registration access token to a row that had just been
inserted — a wasted round-trip every time.
The processor now drives the manager pipeline manually:
PopulateAsync(app, descriptor) fills the in-memory entity from the
descriptor, the RAT hash is stamped onto Props before the row leaves
memory, and manager.CreateAsync(app, secret, ct) performs a single
persist that hashes the client secret and writes the application,
permissions, redirect URIs, and the RAT hash atomically. The
plaintext secret is pulled out of the descriptor before
PopulateAsync so the manager's "client secret hash cannot be set"
guard is not tripped — the dedicated (application, secret, ct)
overload owns the hashing.
Measured impact on the acceptance demo suite (warm-start DCR calls, local PostgreSQL, BCrypt cost factor unchanged at 12):
| Demo | Before | After |
|---|---|---|
dcr_lifecycle |
590 ms | 305 ms |
client_credentials |
591 ms | 237 ms |
authcode_pkce |
517 ms | 302 ms |
introspect_revoke |
545 ms | 446 ms |
me_profile |
288 ms | 276 ms |
mfa_totp |
250 ms | 196 ms |
30–60 % reduction with no change to security primitives: BCrypt cost factor, JWE access-token encryption, RSA signing/encryption key bootstrap, and the OpenIddict validation surface are all untouched. The win is purely in collapsing redundant redb round-trips.
When /connect/authorize rejected a request from the HandleAuthorizationRequest
stage (e.g. prompt=none with no session → login_required, or
consent_required after consent tracking), OpenIddict's Apply handlers do not
fire, so RedbRouteOpenIddictServerHandler was emitting a JSON 400 error body
instead of the RFC-required 302 redirect back to the validated redirect_uri.
The HTTP facade then made it worse for login_required specifically by
overriding the response with a 302 → /login?returnUrl=... to the interactive
login UI — silently violating OIDC §3.1.2.6, which forbids any UI for
prompt=none.
Two coordinated fixes:
RedbRouteOpenIddictServerHandler.ProcessAsync: when the request is rejected for the Authorization endpoint and we have a validatedredirect_uri+ a real HTTP method header, build the302redirect ourselves (?error=…&error_description=…&state=…, or#…forresponse_mode=fragment) instead of falling through to JSON.HandleAuthorizationRequestHandler+SessionCookieProcessors.RedirectToLogin: the handler flags the exchange withprompt_none = trueon the prompt=none reject path; the HTTP processor skips its/loginUI override when that flag is set, so the natural error redirect toredirect_urisurvives the facade.
New regression demo demo_auth_extras.ps1 covers four previously-unprobed
authorization-response surfaces:
- RFC 9207
issparameter present inqueryresponse Location prompt=none+ no session →error=login_requiredatredirect_uriresponse_mode=form_post→ 200 HTML auto-submit form withcoderesponse_mode=fragment→Locationcarries#code=…&state=…&iss=…
/.well-known/openid-configuration was emitting dpop_signing_alg_values_supported
as 13 entries (ES256, ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, PS256, RS256) because OpenIddict's default discovery handler
appends its own short list AFTER our ApplyDiscoveryResponseHandler runs, and
RemoveParameter does not survive the late merge. ApplyDiscoveryResponseHandler
now dedupes every advertised string-array field (dpop_signing_alg_values_supported,
scopes_supported, grant_types_supported, *_auth_methods_supported,
prompt_values_supported, response_modes_supported, etc.) in the materialised
Out.Body immediately before HandleRequest(). New regression demo
demo_discovery_shape.ps1 locks the shape: required RFC 8414 / OIDC Discovery
fields, no duplicates anywhere, DPoP algs restricted to RFC 9449 §5 catalog,
no placeholder/secret leaks.
Three bugs prevented BackchannelLogoutDispatcher from ever firing on
non-interactive flows (ROPC + refresh):
- Subject lookup queried wrong column.
LogoutProcessor.CollectAffected ApplicationIdsAsyncwas filtering authorizations and tokens byKey == userId, but the OpenIddict redb stores write the public GUID subject into_objects.value_guid(viaSetSubjectAsync). Auths/tokens are now resolved by translatinguserId → UserProps.value_guidfirst, then queryingValueGuid == subjectGuid. - Token-only flows were ignored. ROPC and other non-interactive
grants emit access/refresh tokens without creating an OpenIddict
Authorizationrow, so the union missed every ROPC-only client. The collection now also unionsTokenProps.ApplicationObjectIdfor non-revoked tokens. - Dispatcher was resolved from the wrong DI container. The processor
was calling
exchange.ServiceProvider?.GetService<BackchannelLogout Dispatcher>()— but the dispatcher is registered in the Identity child container, not the host SP thatexchange.ServiceProviderexposes. It now resolves throughIRouteContext.GetIdentityServiceOr Default<>(exchange)so per-exchange Identity scopes are honoured.
RFC 7591 Dynamic Client Registration accepts the OIDC Back-Channel
Logout 1.0 client metadata fields (backchannel_logout_uri,
backchannel_logout_session_required) and persists them on the
registered application. PopulateAsync ignores
descriptor.Properties[] (those are OpenIddict's per-store custom
property bag, not redb Props), so the strongly-typed
ApplicationProps.BackchannelLogoutUri field is now written manually
after PopulateAsync in both DynamicRegistrationProcessor and
ApplicationManagementProcessor. New demo
demo_backchannel_logout.ps1 covers the end-to-end flow: DCR a
confidential client with a backchannel URI, ROPC for tokens, POST
/connect/logout with id_token_hint, and assert that an
HttpListener captures the signed logout_token JWT carrying the
required events/sub claims.
DynamicRegistrationProcessor was the only client-creation path setting
ConsentType = Explicit; every other path (BootstrapAdminProcessor,
SeedWebClientHostedService, SeedBackchannelClientHostedService) uses
Implicit. The mismatch broke headless authorization-code + PKCE flows:
HandleAuthorizationRequestHandler saw Explicit and 302-redirected
the caller to /consent, which a non-browser client cannot satisfy
without driving the consent submission form. DCR now defaults to
Implicit, matching the rest of the server's policy. Operators that
need an explicit consent screen for a specific DCR client can flip it
via the management API.
The OAuth 2.0 token-revocation endpoint per RFC 7009 is mounted at
/connect/revocation (matching OpenIddict's discovery document). The
demo_introspect_revoke.ps1 acceptance script was hitting the
non-existent /connect/revoke path on the refresh-token revoke step;
this was a demo bug, not a server bug, but it surfaced because the
acceptance suite drives both introspection and revocation back-to-back.
Demo updated to use the canonical path; the server side already
exposed only /connect/revocation.
Two related fixes for the public read-only federation projection at
/api/v1/identity/federation-providers/public:
- Anonymous access.
ManagementBearerAuthProcessorwas rejecting the request with401 missing_token. The path is now in the auth processor'sanonymousPathPrefixeslist — the public projection is unauthenticated by design (login pages call it before the user has a session). Sits next to the existing exemptions for the password-recovery and account-bootstrap flows and the SCIM discovery endpoints. - Route precedence.
ConfigurePublicFederationProvidersEndpointwas registered AFTERConfigureManagementApi, so the catch-all/api/v1/identity/{**path}matched first and dispatched the request toFederationProvidersController.Get(id="public")which then failed with400 "Id is required". The exact-match route is now registered first. - Cross-context mirror. Tsak isolates context property buckets,
so the HTTP facade (
identity.httpcontext) cannot read theIdentity:FederationProviderslist fromidentity.core's context.context.jsonnow mirrors the providers underidentity.http:IdentityTransport:FederationProviders. The mirror omitsClientSecret— the HTTP-facade path only consumesClientId/Authority/Scopesfor the redirect surface.
Surfaced by the new demo_federation.ps1 (RFC-shape probe: discovery
federation_providers array, public projection with explicit anti-leak
guards against ClientSecret/Authority/Scopes/ClientId,
cross-check between the two lists, then a /connect/external-login
302 verification with an absolute upstream Location).
Two server-config nudges so the existing token-exchange code path is actually reachable from a freshly-registered DCR client:
Identity:Features:EnableTokenExchangedefaulted tofalse, which meanturn:ietf:params:oauth:grant-type:token-exchangewas absent from the discovery document'sgrant_types_supportedarray even though the OpenIddict handlers were live. Default flipped totrueincontext.json.- The grant was missing from
DynamicRegistrationAllowedGrantTypes, so any DCR-registered client requesting the grant got400 "Grant type(s) not allowed: ..."from the registration endpoint. Now allowed.
Surfaced by demo_token_exchange.ps1, which already exercised the
delegation (subject_token + actor_token → act claim) path and
just needed the server to admit the grant. Same demo's DCR
registration also now requests client_credentials (step 4 mints
the actor_token via that grant) and the identity:read scope.
demo_device_code.ps1 covers the happy path of the RFC 8628 device
authorization grant but requires a human to approve the device, so CI
could not run it. The new demo_device_code_ci.ps1 is the
non-interactive companion: it DCR-registers a device-code client,
hits POST /connect/deviceauthorization, asserts the RFC 8628 §3.2
response shape (device_code, user_code, verification_uri,
expires_in required; interval and verification_uri_complete
soft-warn), and polls /connect/token once expecting
error=authorization_pending per §3.5. Wired into run_all.ps1
alongside the existing 21 demos for a 22/22 acceptance suite.
The per-request _idCache / _refCache introduced on RedbTokenStore and
RedbAuthorizationStore (perf entry above) served the documented
2× FindByIdAsync per /connect/token access pattern, but RevokeAsync,
RevokeBy{Application,Authorization,Subject}IdAsync, and PruneAsync
loaded targets through Query<>() (bypassing the cache), mutated and
saved them, but never refreshed the cache. A later FindByIdAsync in the
same request returned the pre-revoke/pre-prune snapshot, breaking both
the in-request "modify then read back" contract and the matching unit
tests (Status observed as "valid" after revoke; pruned tokens still
resolved).
Fix: the bulk-mutation paths now refresh the cache entries with the
just-saved instances immediately after SaveAsync, and the soft-delete
path evicts the ids from _idCache (token store also clears _refCache
wholesale because the prune doesn't carry the refId↔id mapping). The
matching UpdateAsync_StaleHash integration test was rewritten to use
two independent store instances so the per-request cache no longer
collapses the "two concurrent requests" copies into one shared reference.
When OpenIddict rejected /connect/authorize because the supplied
redirect_uri was unregistered, malformed, or otherwise unauthorized
(error documentation IDs ID2043 / ID2052 / ID2095 / ID2100),
RedbRouteOpenIddictServerHandler.ProcessAsync still built a 302 to
the attacker-supplied request redirect_uri with the error on the
query string. RFC 6749 §3.1.2.4 / §4.1.2.1 forbid using a non-validated
redirect_uri as a redirect target — the response must be inline so
the browser never reaches the attacker URI with the framework's state
and error parameters attached.
Fix: the rejection path now inspects context.ErrorUri and skips the
redirect-to-redirect_uri block when any of the four redirect_uri-
specific OpenIddict error IDs is present, falling through to
WriteResponseToExchange so the error surfaces on the response body
instead. Covered by RedirectUriValidationTests with both unregistered
and registered-prefix-plus-query attacker shapes.
The interactive-/login redirect introduced for missing-session
login_required (Fixed entry above) treated every login_required
rejection as "redirect the browser to the local login page". That was
wrong for prompt=login and max_age staleness checks — the user is
already authenticated, so /login would auto-recognise the cookie and
loop back to the same authorize request. OIDC §3.1.2.1 / §3.1.2.6
require these "force re-auth" cases to surface error=login_required
on the validated redirect_uri so the RP can drive the re-auth flow
(clear its session, retry without prompt=login, etc).
HandleAuthorizationRequestHandler already flagged the exchange with
force_login=true on both branches; the fix is to lift that flag in
RedbRouteOpenIddictServerHandler.ProcessAsync and exclude it from the
interactiveDeferred condition. Only the genuine missing-session path
(no prompt, no max_age flag) still defers to /login; everything
else surfaces on the callback. demo_prompt_max_age.ps1 (9 steps) is
the regression probe.
IdentityRouteContextExtensions.GetIdentityService<T>(exchange) falls
back to IRouteContext.GetServiceProvider() when no factory was
registered via RegisterIdentityScopeFactory (every test fixture that
constructs the route context with the host root SP directly hits this
path). The fallback returned the host root SP as-is. Resolving a
Scoped service (IOpenIddictApplicationManager) from a root SP
captures the underlying Scoped IOpenIddictApplicationStore =
RedbApplicationStore at root lifetime, which silently promotes it to
a process-wide singleton — its per-request _clientIdCache then
persists across every request. Cleanup code that opened its own
CreateScope() mutated a different store instance, so the captive
store kept serving the pre-cleanup snapshot. The
BootstrapAdminEndpointTests "first call → 201" cases consistently
saw 409 OIDC client '…' already exists because the captive store
returned the ghost row id from a prior test's create.
Fix: when the fallback fires, build a per-exchange scope from the root
SP's own IServiceScopeFactory and cache it on the exchange (matching
the registered-factory path). Each request now gets its own fresh
RedbApplicationStore with an empty _clientIdCache, mutations stay
within the request, and there is no captive shadowing of DB state.
Verified — all 9 BootstrapAdminEndpointTests pass.
Three identical Tsak workers loading the same .tpkg set against the
same PostgreSQL is a production cluster — there is no service mesh,
no leader election service, no Redis. The DataProtection key ring and
the OpenIddict signing keys are shared through the redb object store;
cleanup timers (PruneAuthorizations, PruneTokens, PruneRevokedSids)
run leader-only via RouteBuilder.Cluster(true), which uses
PostgreSQL advisory locks to elect exactly one runner per epoch.
- 1783 xUnit tests cover the engine, every store, every claim handler,
every
/me/*route, every management route, the Http facade, the Ldap sync, the Dpop middleware, and the end-to-end OIDC flows against a real PostgreSQL viaProductionBootstrapFixture/ProductionHttpFixture. All green on this preview. - Header-negotiation, native-consent happy path, atomic authorization-code single-use (including a 16-way concurrent replay test), refresh token rotation, DPoP nonce/jti replay, and the GUID-sub migration are each covered by dedicated test classes.
- Integration tests share fixtures via xUnit
[Collection]markers and clean per-client OpenIddict authorization state inIAsyncLifetime.InitializeAsyncto keep test ordering irrelevant.
- gRPC / RabbitMQ / AMQP / IBM MQ / SignalR transport facade
.tpkgpackages (the engine is transport-agnostic; only HTTP ships in this preview). - Public NuGet packaging + signing pipeline.
- Security review pass on the cryptographic surfaces (DPoP, PAR request-object signature verification, signed logout tokens, key ring rotation policy).
- Public migration guide for relying parties moving off the bigint sub.
- Performance baseline: 99p latency targets for
token,authorize,introspect,userinfoagainst PostgreSQL on commodity hardware.