Релизы redb.Identity

Подписаться на релизы

Все релизы redb.Identity, от свежего к старому. Текст — как в репозитории, без пересказа.

4.2.0

Security — /connect/logout acts on the browser's own session; a userId in the request no longer chooses whom to sign out

The logout processor read userId from the request body (the GET route maps the query string the same way) and, when no session cookie came with the request, signed that user out: every session revoked and a back-channel logout token sent to every relying party holding a live token for them. /connect/logout is the OIDC end_session_endpoint, reachable anonymously by design, so the value was the caller's word: POST /connect/logout with userId=1 and no cookie ended the administrator's browser sessions and told the relying parties so, and user ids are sequential. The parameter had no legitimate caller — the two facade routes are the endpoint's only clients, and an operator signing another user out goes through the sessions management API behind the management gate. The processor now takes the user and the session from the verified session ticket alone (the headers ReadSessionCookie writes; the facade strips those names off incoming requests first). A request with no session completes with nothing ended — RP-Initiated Logout 1.0 §2 still expects the redirect to post_logout_redirect_uri — instead of being refused; externally that is what already happened, the signed-out page having overwritten the processor's error. LogoutSubjectBindingTests pins it: an anonymous POST and GET naming the victim leave the session active, the browser holding the cookie still ends its own, a bare id_token_hint completes and ends nothing.

In the same processor, the id_token_hint subject was compared with the numeric core user id, while sub is the user's public subject GUID, so every hinted logout logged a mismatch and ignored the hint. It is compared with the subject GUID now.

Security — a logout without a valid id_token_hint asks the End-User first

The session cookie is SameSite=Lax, so a cross-site top-level navigation carries it: a link on any page could send the browser to /connect/logout and end the visitor's OP session — the cookie proves whose session it is, not that its owner wants it ended. A relying party proves intent with a valid id_token_hint whose sub is the session's user, and such a request signs out at once, as before. Without it the OP now answers with a confirmation page (GET and POST alike): its form carries a state bound to the user and the session and valid for five minutes, and posting that state back is the confirmation. A foreign page cannot read the state (same-origin policy) nor forge it (DataProtection), a state issued for another session confirms nothing, and the page clears no cookie and performs no redirect until the End-User confirms; the relying party's post_logout_redirect_uri, state and client_id travel through the form so the redirect back still happens. Duende's logout prompt and Keycloak's confirmation without id_token_hint behave the same way. The decision is made in the core (LogoutProcessor with LogoutConfirmationProtector) and the HTTP facade only renders the page, so another facade would render its own. A browser that used to sign out with one request without a hint now sees one page first. LogoutConfirmationTests pins it: the page instead of the logout, the confirmation, a forged or foreign state, and the relying party's parameters surviving the round trip.

Security — signing a user out everywhere revoked their sessions and nothing else

SessionService.LogoutAsync is what a password change, a password reset, an operator's logout, a SCIM deactivation and a user disable rely on to end every grant the user holds. It revoked the sessions and then looked for the user's authorizations by the internal user id in Key; since V4 the OpenIddict stores key authorizations and tokens by the user's public subject in value_guid, so the lookup found nothing and every refresh token kept working — after a password reset, the tokens of whoever had the old password kept refreshing. Tokens issued by the password grant have no authorization row at all and were never in reach of that step. The method now resolves the public subject and revokes the user's authorizations and their valid tokens by it, in batches; other subjects' tokens at the same application are untouched. FullLogoutRevokesGrantsTests runs the real path — a refresh token from the authorization code flow and one from the password grant, then the operator's logout — and both refreshes must fail; they succeeded before. SessionServiceTests now seeds grants the way the stores write them.

Fixed — the relying party's state comes back on the post-logout redirect

RP-Initiated Logout 1.0 §3: when the RP passed state, the OP returns it as a query parameter on the redirect to post_logout_redirect_uri. It was dropped. It is preserved with the redirect URI and appended to the Location, through the confirmation page as well.

Fixed — back-channel logout for a session reaches the relying parties that used it, with the user's public subject

A browser session is opened at /login, before any relying party is involved, and it did not record which relying parties later obtained tokens through it. A per-session logout therefore had nobody to notify (Back-Channel Logout 1.0 §2.1 asks the OP to notify the RPs the End-User signed in to through the session being ended), and the logout_token carried the internal user id as sub, while the id_token the RP holds carries the public subject GUID, so an RP could not have matched it anyway. The session now keeps the list of relying parties that obtained tokens through it (SessionProps.ClientApplicationIds, appended at the authorization endpoint under a row lock, so two tabs cannot drop each other's entry), a per-session logout notifies exactly those with the session's sid, a logout of every session notifies each session's relying parties with its own sid and token holders outside any session (ROPC, device flow) with sub alone, and sub is the public subject in every case. Sessions opened before this release have no list and notify nobody, as before. BackchannelLogoutOnSessionEndTests runs the whole path against a relying party endpoint on the same HTTP stack: sign in, authorize, RP-initiated logout with id_token_hint, a logout_token whose sub and sid equal the id_token's; it times out without the binding. demo_backchannel_logout.ps1 follows the same browser path now.

Changed — with configured credentials and the redb-backed signing-key store both present, MintingKeySource says which one mints

The option's documentation said the store's keys are appended after configured SigningCredentials / EncryptionCredentials, so that an HSM-backed key stays primary; the post-configure inserted them first, so the store minted. OpenIddict signs with the first compatible signing credential and encrypts with the first encryption credential, while every entry validates and is published, so the order of two lists was deciding who mints — and the pair is legitimate in both directions, as the state of a deployment moving its keys onto an HSM or off one. That is the operator's decision, not a list order's: the new RedbIdentityOptions.MintingKeySource (Configured or Store) is required when both sources are present — registration refuses the pair without it — and refused when only one source is configured, since it would decide nothing. The post-configure places the store's keys after or before the configured ones accordingly, the active store key first among them as before. Deployments with the store alone (the shipped configuration) are unaffected. SigningKeysBootstrapTests and SigningKeyPostConfigureTests pin the refusals and both orders.

Fixed — ephemeral keys and the redb-backed signing-key store refuse to be enabled together; the store always seeds an encryption key

AllowEphemeralKeys=true together with UsePropsSigningKeyStore=true registered an ephemeral key first and left it in the credential lists beside the store's keys: in this replica's JWKS only, trusted for validation, and the encryption key for every token until the store had one. Not exploitable — its private half never leaves the process — but it is the per-process failure the store exists to remove, hidden behind a persistent store. AddRedbIdentityServer now refuses the pair. SigningKeyInitListener seeded an encryption key only while access-token encryption was on; OpenIddict encrypts authorization codes, refresh tokens, device and user codes and its own state tokens whatever DisableAccessTokenEncryption says, and refuses to build its options without an encryption credential, so a store holding a signing key and no encryption key was a server that could not start. It seeds both kinds now, and the registration refuses a configuration with no encryption credential whatever that flag says, naming the options in the message. SigningKeysBootstrapTests and SigningKeyInitListenerTests pin both.

Security — a retired signing key is offered to nobody, by construction rather than by registration order

The post-configure that feeds OpenIddict from the redb-backed signing-key store built two pools. Keys inside their NotBefore..NotAfter window became the credentials OpenIddict mints with. A broader "validation pool" — every persisted key past NotBefore, retired ones included — was written into TokenValidationParameters.IssuerSigningKeys, so that, its comments said, tokens signed by retired keys would keep validating "during the grace window". Retirement sets NotAfter to the moment of retirement, so that pool kept a retired key for as long as its row existed: had a validator read that list, the one reaction an operator has to a leaked key would have reached the JWKS and minting, and nothing else.

Checked against the real pipeline before anything was changed, and the list was never read: OpenIddict's own post-configure runs after this one (this one is registered in AddCore, OpenIddict's in AddServer) and rebuilds the validation parameters from the credentials — a token signed by a retired key was already refused (ID2090) at introspection and by the validation stack the management API uses. What kept it safe was the order two registrations happen to have; a refactor that moved one of them would have made the stale pool live. The component no longer has one. It takes the store's working set only (GetAllAsync, in-window keys), so a retired key appears in no list, and it writes the validation parameters as a projection of the credential lists — the same two lines OpenIddict writes — so the result is the same whichever post-configure runs last. That also ends the clobbering of credentials other configurers added (the file's own rule was "never clear the list"; the assignment did): they stay, and they are trusted for validation too.

Rotation is unchanged: the demoted key keeps its own NotAfter and validates until then, which is the handover window relying parties need. Retirement now means what it says everywhere.

SigningKeyPostConfigureTests pins the component alone — a retired key in no list, parameters equal to credentials, a host-added credential kept and trusted; three of four fail on the previous code. SigningKeyRetirementTests runs the store-backed key path end to end for the first time in the suite: mint, rotate (the old token still validates), retire (introspection and the validation stack both refuse it).

Security — an open redirect after sign-in through a control character in returnUrl

Every point where a returnUrl becomes a Location header — login, MFA (twice), federation, consent — asks LoginPageProcessors.IsValidReturnUrl first, and the check refused absolute addresses, //host and /\host. It did not refuse control characters. A browser removes ASCII tab and newline from a URL before resolving it (WHATWG URL), so returnUrl=%2F%09%2Fevil.example — /\t/evil.example once decoded — passed the check and landed the user on //evil.example, another host, right after they typed their password here. The check now refuses any control character, as ASP.NET Core's IsLocalUrl does for the same reason. ReturnUrlOpenRedirectTests pins it; the control-character cases fail without the fix.

The federation callback answers with the new session cookie and with the expiry of the per-flow binding cookie, and it joined the two into one Set-Cookie value with ", ". A browser does not split Set-Cookie on commas (RFC 6265 §3): it read the fold as one cookie — the session — whose attributes ended with the binding cookie's Max-Age=0, and since the last Max-Age wins (§5.3) it deleted the session as it arrived, while the binding cookie was never cleared. The HTTP consumer already writes an array value as one header line per entry, and the MFA cookies already used that through a private helper of their own. There is now one IdentityCookieFormatter.AddSetCookie: a second cookie turns the value into an array, a single one stays the plain string every reader expects. The federation, session and MFA writers all go through it, and the session writers no longer assign the header — no route pairs two of them today, but a second cookie would have silently replaced the first, including the re-authentication marker. SetCookieFoldingTests reads the headers the way a browser does.

The routes answer on the request message, and the cookie writers add to whatever Set-Cookie is already there — which, on a login, was the caller's own request header. The HTTP consumer refuses to echo a request header only while it is the very object the client sent (it stopped matching by name so a client can no longer suppress a header the route wrote, redb.Route 4.2.0), and the array built around the caller's value is not that object, so both cookies went out. A browser cannot send Set-Cookie on a request, so this only ever reached the caller itself. Set-Cookie is now one of the names stripped on ingress on every transport. InboundSetCookieTests signs in through the real HTTP pipeline with the header planted and fails without the fix. Since redb.Route 4.2.0 the HTTP consumer also drops Set-Cookie, with the other response-only fields of RFC 9110, before the exchange is built; Identity keeps its own strip all the same — it covers gRPC and SOAP as well, and the names Identity refuses to take from a caller are Identity's to declare.

Fixed — three HTTP routes did not strip the reserved inbound headers first

The HTTP facade removes the caller-supplied names it trusts internally (session_user_id, user_id, ip_address, …) as the first step of a route, documented as "every HTTP route". The management catch-all (http-management-api) and the SCIM catch-all (http-scim-api) never did, and the SCIM discovery route did it second. Nothing on those routes reads a reserved name today — their controllers build the inner message from scratch — so this was a structural gap, not an exploit. All three strip first now, and HttpRouteReservedHeaderStripTests fails if a route in the HTTP route builder does not.

4.1.1

Changed — a WS-Trust refusal is the STS's answer, not a failure of the route

The SOAP facade threw its WS-Trust faults. The caller received the right fault either way, but every throw also failed the exchange, so each correct refusal — a malformed RST, bad credentials, a scope the caller may not have, a caller over the rate limit — counted against soap-identity-sts as a route error, headed for a dead-letter channel, and showed the STS as a failing route to whoever supervises it. The demo suite's three negative probes alone put it on a supervisor's "needs attention" list, and a real breakage would have drowned in such noise.

redb.Route.Soap now lets a consumer route answer with a fault: put redbSoap.faultCode and redbSoap.faultString on the reply and return normally, and the consumer sends the soap:Fault while the exchange stays successful. The STS uses exactly that. A refusal is raised internally as WsTrustRefusal and turned into the reply at the boundary of the two steps that can refuse (MapRequest, MapResponse); the route stops there, so nothing after a refusal runs.

What stays a failure is our own breakage: Core gave no answer, is unavailable, timed out, or issued no token. Those remain soap:Server faults thrown as SoapFaultException, and the error count and dead-letter channel see them — which is what they exist for. Rate limiting is deliberately on the answer side: it is the limiter working, the same way the HTTP facade's 429 is an answer. Its code is unchanged (soap:Server, "wait, do not rewrite the request").

Nothing changes on the wire: same fault codes, same reasons, same HTTP status. The existing SOAP flow tests pass unchanged. SoapProtocolFlowTests now also reads the route's own redb.route.exchanges.failed counter, filtered to its fixture's endpoint: before this change a malformed RST and a wrong secret counted as two failed exchanges, now as none.

Deployment order: this needs the redb.Route.Soap that carries the fault-as-reply form. On a host whose shared redb.Route.Soap predates it, the headers are ignored and a refusal would leave as a malformed success reply instead of a fault. Identity and Route ship together, as always; a host built from an older Route must be rebuilt before this module is deployed to it.

Fixed — a missing management route answers 404, and a crash in a controller answers 500

The management surface answered 400 to a path no controller action matches, and 400 again when an action threw. On gRPC both arrived as INVALID_ARGUMENT. A missing route told the caller their request was malformed; an exception in our own code told them it was their fault — which is how a disabled feature once showed up as a 400 while the log held an unhandled exception.

The controller dispatcher had already said the right thing: it writes NotFound with 404, or InternalError with 500, both into the body's error field and as the status. The facades then read the error field, looked it up in ManagementErrorCodes — which knows the controllers' own codes (not_found, duplicate, …) and nothing of the dispatcher's — and replaced the status with the table's fallback. The table exists for exactly one case, a controller that returned an error document wrapped in a 200; it was reaching past it.

ManagementErrorCodes.DecidedErrorStatus now names the status already decided — from redbHttp.ResponseCode or status.code, whichever the dispatcher wrote — and both facades leave such a status alone, consulting the table only when nobody stated a code. That is the rule IdentityVerdict has always stated for the rest of Identity: the status wins where there is one. A status Core sets through a controller (a 503 during an outage, say) is kept by the same rule. Teaching the table the dispatcher's strings would have made the symptom go away and tied Identity to another package's constants; it was not done.

ManagementStatusPreservationTests pins both transports, through the real HTTP pipeline as well as at the mappers, and pins that controller error documents still map exactly as before. Without the fix the pipeline test answers 400 and the gRPC one INVALID_ARGUMENT for a 500.

4.1.0

This release carries security fixes. An administrative scope is no longer granted without a role that carries it, and an anonymous client registration can no longer create a client that administers the server. The entries marked Security! below say what was possible before and what changed.

Security! — an administrative scope now requires a role that carries it

OAuth's scope check asks one question: may this client request this scope? Nothing in it asks whether the person signing in should hold it. The admin console legitimately asks for identity:manage, so every account that could sign in to it received the master management scope, and the management API — validating the token exactly as it should — admitted them. Reproduced end to end on a running instance: an account created seconds earlier, with no roles and no groups, listed every user and deleted one. Roles were already resolved at issuance (AttachRoleRegistryClaims) but only ever added scopes; nothing ever withheld one.

RestrictAdminScopesByRoleHandler supplies the missing half. For user-bound grants it computes the scopes the user's effective roles attach — direct assignments and those inherited through groups, via RoleService.GetEffectiveScopeNamesAsync, which already existed — and removes every administrative scope that set does not contain. Administrative means Identity's own surface: identity:* except the self-service identity:account, plus scim. An operator's own scopes are untouched unless named in Identity:AdminScopeEntitlement.

Three decisions worth stating plainly, because each could have gone the other way:

  • It removes, it never rejects. A user who asks for more than their roles allow still signs in, without those scopes. Rejecting would turn a missing role into a failed login, and the console asks for identity:manage on behalf of everyone. Every removal is logged with the user and the scopes.
  • It fails closed. If roles cannot be resolved the administrative scopes are dropped rather than issued on trust. That costs an administrator a refresh after a database hiccup; the opposite costs everyone their user list.
  • Refresh re-evaluates. The handler runs on every sign-in, refresh included, so revoking a role takes effect on the next refresh instead of lasting as long as the grant.

client_credentials is deliberately untouched: it has no user to entitle, and the application's own scp:{scope} permission remains the authoritative gate there.

Upgrades keep working: SeedAdminRoleScopesListener attaches the management scope to the system admin role at startup when that role carries no scopes at all — the state every installation predating this change is in, and the state that would otherwise lock the only administrator out of their own console. A role whose scopes an operator has curated is never touched. Identity: AdminScopeEntitlement:Enabled=false turns the gate off from configuration alone, so a deployment that still manages to lock itself out can recover without a rebuild.

Pinned by AdminScopeEntitlementTests through the real token pipeline, asking the server what the issued token carries rather than trusting the token response (RFC 6749 §5.1 makes its scope member optional when nothing changed): no entitling role, no management scope; an entitling role, the scope is there; client_credentials unchanged. With the gate switched off the first of those fails with {openid, identity:manage} — the vulnerability, reproduced in a test.

Verified on a running Tsak worker, not only in the suite: a freshly registered account with no roles received openid alone and got 403 from the user list, the role list and a delete; an account in the admin role received identity:manage and administered normally. Self-service is unaffected — the console requests identity:account as well, and that scope is exempt.

Fixed — the shipped module entry point ran a shorter startup chain than the embeddable one

Identity has two entry points. redb.Identity.Core/Module/InitRoute.cs boots it inside a host process and is what the test fixtures use; redb.Identity.Core.Module/InitRoute.cs is what Tsak discovers in the .tpkg, and therefore what every deployment actually runs. Each keeps its own chain of lifecycle listeners, so a listener added to the first and forgotten in the second is green in the whole suite and absent in production.

Two listeners had drifted:

  • SeedAdminRoleScopesListener — the upgrade path for the gate above. On the running worker the admin role stayed empty, so an administrator in the admin role was refused identity:manage just like anybody else. Found by deploying the gate and watching an administrator get locked out.
  • V4UniqueBackfillListener — the V4-UNIQUE transition backfill, whose own documentation says it must run before the seed listeners. No Tsak deployment had ever run it; on the first boot after this change the worker reported candidates=356 repaired=1 duplicates=0 and retired the pre-V4 indexes.

ModuleListenerParityTests now fails when a listener the embeddable path registers is missing from the shipped one. The reverse stays allowed: the module entry point legitimately owns host-specific listeners (child-container and bridge-scope disposal, the credential seeders from its own container).

RoleScopeQueryIsolationTests pins the neighbouring assumption the diagnosis had to rule out: a role is the parent of both its users and its scopes, and reading the users as scope attachments would make an empty role look curated.

Security! — an anonymous registration cannot create a client that administers the server

Dynamic client registration (RFC 7591) has two modes: open, and protected behind an initial access token. Open was what shipped, and the only limit on what a registration could ask for was DynamicRegistrationAllowedScopes — a list that includes every granular administrative scope so the demo suite can probe each admin surface. Anyone able to reach POST /connect/register could therefore register a client_credentials client carrying identity:users:write, take a token, and delete users. No role, no password, no operator involved. It was used exactly that way during this cycle's work, to clean up probe accounts, which is how it came to the owner's attention.

The scope-entitlement gate above cannot help here: client_credentials has no user to entitle, so the client's own permissions are the whole gate — and those permissions are precisely what registration was handing out.

So the rule sits below deployment policy, in DynamicRegistrationProcessor: while registration is open, a scope this deployment counts as administrative is refused, whatever the allow-list says. What counts is Identity:AdminScopeEntitlement, the same definition the issuance gate uses, so an operator who declares their own administrative scope is covered by both at once. Its Enabled switch is deliberately not consulted — that switch exists to recover a locked-out administrator and must not quietly reopen anonymous registration of administrative clients.

Registering administrative clients dynamically remains possible, through the mode RFC 7591 defines for it: set DynamicRegistrationInitialAccessToken, and every registration then carries a credential the deployment issued, with the allow-list governing as before. The shipped context sets one, for the same reason it ships a seeded admin password and a dev pepper: the demo suite registers admin-scoped clients and something has to authorise it. That value is as public as the file it lives in, and the context says so — production rotates it, or sets it to null and trims the allow-list to the user-info scopes.

The demo suite presents that token on every POST /connect/register, and takes it from IDENTITY_DCR_TOKEN when set — a host whose token differs from the shipped default runs the demos by exporting that variable, and changes nothing in the scripts.

AnonymousDcrAdminScopeTests pins both directions, including that open registration still serves the ordinary clients it exists for. Verified on a running worker as well.

Fixed — a switched-off feature answers "no such endpoint" instead of failing

Registration, e-mail verification, e-mail change and WebAuthn bind their direct-vm:// routes only when enabled, while the facades mount their controllers regardless. A call to a disabled feature therefore reached a producer with nothing behind it, and the resulting InvalidOperationException left the caller with a generic failure and the log with an unhandled-exception stack trace — observed as POST /account/register answering 400 with No consumer registered for direct-vm endpoint 'identity-account-register'.

IdentityControllerBase.Forward now asks the shared registry whether the route is bound and, when it is not, answers the standard not_found document, which ManagementErrorCodes turns into 404 on every transport. That is the answer the client SDK already reads back as registration_disabled, and it is what the API documentation said all along.

The question goes to the registry rather than to a feature flag on purpose. The flag lives in Core's options and each facade binds its own; copying it across the boundary would put the answer in two places and let them disagree, while what is actually bound cannot disagree with itself.

Security — what self-registration meant on 4.0.1, and why the shipped context keeps it on now

During this cycle Registration.Enabled was turned off in the context this repository ships, then turned back on. Both moves belong in the record, because the reason was never registration itself.

On 4.0.1 the scopes a request receives are the ones the client is permitted to ask for, narrowed by nothing that depends on the user. Roles only add scopes (AttachRoleRegistryClaims); the one mechanism that could withhold one — ScopeRequiredGroups — is opt-in and unset by default. The admin console asks for identity:manage at sign-in, so every account that could sign in to it held the master management scope, and the server-side check passed for them exactly as for an administrator. Confirmed against a running instance, reads and writes alike; the finding is the owner's. With that gap open, self-service registration was the path from anonymous to full administrator in two requests, so it was closed.

Operators running 4.0.1: keep self-registration off, and treat every account that can sign in to the console as an administrator. That advice stands for that release and is the reason this entry stays.

The gap is closed in this cycle by the entitlement gate above, and with it closed the shipped context turns registration back on: this context is the showcase one, and an image where a visitor can create an account and walk the sign-in, MFA and self-service flows is the point of shipping it. A self-registered account now holds openid, profile and identity:account, and nothing that can administer. The code default remains false, so a host that configures nothing still ships closed, and the context says in so many words that production should set false unless public sign-up is a wanted feature.

One door the gate does not touch, stated plainly because a showcase invites probing: dynamic client registration is still open in this context and DynamicRegistrationAllowedScopes still includes the granular admin scopes, so any caller of POST /connect/register can mint a client_credentials client that administers. client_credentials has no user to entitle — the client's own permissions are the whole gate there, by design. An exposed instance needs DynamicRegistrationInitialAccessToken set or that list trimmed to the user-info scopes.

Fixed — Administration → Federation crashed, and the top bar showed a subject instead of a login

Two findings from a user of the 4.0.1 console.

FederationList passed Icon="cluster" to UiEmptyState, which has no such parameter — it takes an Illustration slot. Blazor answers that with a runtime error, and the compiler cannot see it, so the page died on every visit where no provider was registered yet: "Object of type 'UiEmptyState' does not have a property matching the name 'Icon'". The icon now goes through the slot. The page tests next door only assert that a route exists and refuses anonymous callers, which is why a page that threw on sight counted as covered; AdminPagesRenderTests renders it instead, and against the old markup it reproduces the user's error verbatim.

The signed-in user was shown as an opaque subject. The OP keeps preferred_username and name out of the id_token on purpose — their destination is the access token, so a relying party cannot forward the user's PII in a token it passes on — and the BFF chose its name claim while only the id_token was in hand, leaving sub. Userinfo arrives a moment later carrying the login, but the identity had already been named, and NameClaimType is fixed at construction. Both sign-in paths now pick the name claim after the userinfo merge (PrincipalNaming, shared by the backchannel client and the redirect flow), in the order preferred_username → name → email → sub; the subject remains the fallback, because a poor label is better than a blank one. The redirect flow also needed the userinfo claims to be mapped onto the principal at all, which it was not doing.

Changed — a module cannot ship its own version of a host assembly, and the packer now says so

pack-tpkg.ps1 force-included Microsoft.IdentityModel.*: those DLLs went into the package even though the host had them, on the reasoning that the host shipped an older, incompatible major. That never worked and cannot work — ModuleAssemblyLoadContext asks the Default ALC before probing the package, as it must or contract types would split, and since redb.Tsak 4.1.0 its LoadedAssemblyTracker prefers the host copy too. The force-include shipped 1.26 MB the loader never looked at, and a Troubleshooting note advising a cure that cures nothing. Removed: Core.Module is 25 → 19 DLLs, Http 5 → 2.

What the force-include was reaching for — never running against a host assembly of the wrong version — is now a gate instead of a copy. For every DLL skipped as host-provided the packer compares the assembly version it would have shipped with the version the host will actually load, and a major divergence stops the packaging with both versions, the host's path, and where each pin lives; a minor one is a warning, because the host copy is what loads either way. Until now that divergence was silent: the module compiled against one version, the host loaded another, and it surfaced far away as a FileNotFoundException inside OpenIddict. The gate was checked by pointing it at an older Microsoft.IdentityModel.Tokens (6.35.1 against our 8.16.0): packaging stops before the archive is written, saying module 8.16.0.0, host 6.35.1.0.

This does not give a module version independence — nothing on the packaging side can. That would need a shared context for a group of packages (Identity's core and its facades deliberately share dependencies with each other, so per-package isolation cannot express it); recorded with the Tsak side as F-13a and deliberately not built, since the versions are aligned today.

Fixed — packages no longer carry shared-framework assemblies

pack-tpkg.ps1 built its "the host provides this" exclusion set from the worker's bin and Libs\shared only. While build-shared copied framework assemblies into that layer, names like Microsoft.Extensions.Options were excluded by accident. Tsak stopped copying them on 2026-09-18, and the next pack put ten Microsoft.Extensions.* 10.0.3 assemblies inside redb.Identity.Contracts.tpkg (66 KB → 294 KB) — while the host resolves those same names from its own framework, 10.0.8.

Nothing broke at runtime: ModuleAssemblyLoadContext asks the Default ALC before probing the package, so a packaged copy can never win a resolve. It was dead weight that LoadedAssemblyTracker byte-loaded a second time into the non-collectible Default context (fixed on the redb.Tsak side in 4.1.0).

The exclusion set now also scans both shared frameworks — Microsoft.NETCore.App and Microsoft.AspNetCore.App, because Microsoft.Extensions.Configuration / .Options / .Logging / .Diagnostics.HealthChecks live in the ASP.NET one. The version is picked as the highest install of the worker's own major, compared as a version and never as text (a string sort ranks 9.0.20 above 10.0.8); a missing framework throws instead of silently packing the assemblies. Contracts.tpkg is back to two DLLs and 65 KB; the other four packages are unchanged, DLL for DLL, so nothing the modules actually need was swept out with them.

4.0.1

Security — the management surface no longer trusts a transport

The core management routes — direct-vm://identity-manage-*, identity-manage-mfa, identity-me-*, identity-scim-*, identity-revoked-sids — never see a bearer token themselves. Each facade (HTTP, gRPC, SOAP) validates it on the identity-auth-management hop, which leaves identity:management-scopes and its siblings on the exchange, and forwards. The core routes carried no gate of their own: the design assumed that whoever reaches direct-vm is one of our facades. RequireSelfOrAdminProcessor (the B8 IDOR rule on identity-manage-mfa) wrote that assumption down and acted on it — an exchange without a management context was admitted as an "internal trusted caller", on the reasoning that direct-vm is not network-reachable.

That reasoning does not hold in a Tsak worker: the direct-vm registry is one per process and shared by every module loaded into it. Any module could send to identity-manage-users and administer users, or to identity-manage-mfa and reset anyone's MFA, with no token and no context — the routes ran the business processor and answered. Nothing in the route DSL said so; the trust was inferred from the absence of a property.

Every route on that surface now starts with RequireManagementContextProcessor: no identity:management-scopes on the exchange, no idempotency lookup, no transaction, no business processor — a generic 403 not_authorized (the same Problem Details every gate on the surface answers with, so nothing about the target leaks) and a warning on the security log naming the route and the operation. RequireSelfOrAdminProcessor refuses the same exchange too, so the rule holds even on a route without the gate. There is deliberately no in-process bypass and no option to declare one: an in-process module that needs the management surface obtains a context the way the facades do — a token with the scopes it needs, validated on identity-auth-management. Trust is a property of the exchange, established by that hop, never of the transport. direct-vm is a transport like the others.

The facades are unaffected: their exchanges already carry the context, and Exchange.CreateChild copies it into the child exchanges the management controllers and SCIM bulk create. Tests that send to the core routes directly now say which caller they play (ManagementCaller.Admin by default on the fixtures — the fixture stands in for a facade that validated a bearer — and ManagementCaller.None to probe the gate). ManagementSurfaceGateTests sends to every constant of the guarded surface without a context and expects the 403; run against the previous code, the management routes answered 500 (the business processor had already run and choked on the empty body), the self-service routes 401 from their own caller check, identity-manage-roles and identity-revoked-sids answered as if nothing were wrong, and RequireSelfOrAdminProcessor let the exchange through. All of that is now the one 403.

Changed — the test gate runs all three test projects

The Identity gate used to be redb.Identity.Tests alone. The solution carries two more test projects, redb.Identity.Client.Tests (the SDK) and redb.Identity.Web.Tests (the BFF), and nothing ran them: that is how RevokedSidsPollHostedServiceTests stayed red from July, DevTlsContractTests from May, and UsersClientTests.SearchUsers_GETs_with_query from June — the last one stale since the non-paginated search moved onto the paginated endpoint (page size 200, PagedResult on the wire); the test still stubbed a bare array and the old URL. Brought to the current contract.

scripts/test-gate.ps1 -Provider sqlite|postgres|mssql now runs the three projects and prints one summary; the matrix is the three invocations in parallel, as before. Client and Web tests touch no storage and add about three seconds. Documented commands updated; the BFF tests are now part of the gate.

Changed — Web component tests on bUnit 2

redb.Identity.Web.Tests pulled AngleSharp 1.2.0 through bunit 1.40.0, and every build flagged it with NU1902 for CVE-2026-54570 (mXSS in the HTML5 parser, patched in 1.5.0). Pinning a patched AngleSharp directly does not work — 1.5.0 and 1.8.1 were both tried — because bunit 1.40 is compiled against the 1.2 API and fails at runtime with MissingMethodException (IHtmlCollection.get_Item).

The test project now uses bunit 2.11.3, which depends on AngleSharp 1.8.1; the warning is gone without any pin. The migration touched one file, Phase3ComponentTests (seven tests): the base class is BunitContext, components render through Render<T>(), and the interaction helpers take IRenderedComponent<TComponent> in place of the removed IRenderedFragment. Queries and event dispatch (FindAll, Change, Input, Click, Markup) carried over unchanged, and every assertion is the same — the tests that check the exact request a form sends (Owner == "application:5", blank optionals as null) pass only if the events really reach the components. Test-only change; redb.Identity.Web.Tests 54/54.

Changed — Identity.Web on the ASP.NET packages of its own framework

redb.Identity.Web targets net10.0 but pinned Microsoft.AspNetCore.Authentication.OpenIdConnect, …Authentication.JwtBearer and …Components.Authorization at 9.0.0 — packages of the previous major on a 10.0 application. Nothing failed, but those three were the whole reason the BFF resolved Microsoft.IdentityModel 8.0.1 while the rest of Identity and the Tsak worker run 8.16.0. The two authentication packages now follow the TFM (Microsoft.AspNetCore.Mvc.Testing in redb.Identity.Web.Tests likewise); Components.Authorization is not raised but dropped — the SDK reports it framework-provided (NU1510), so the reference only ever added a version to argue about. The two IdentityModel roots the handlers actually use — Microsoft.IdentityModel.Protocols.OpenIdConnect and System.IdentityModel.Tokens.Jwt — are pinned at 8.16.0 the way redb.Identity.Http pins its own. One IdentityModel version across Identity. The BFF is a separate process, so this is hygiene rather than a runtime fix — but the code that validates the OP's tokens in the BFF now runs on the same library revision as the OP that issues them.

The patch is 10.0.9, not the current 10.0.12, and that is a choice: from 10.0.10 the authentication packages require Microsoft.IdentityModel ≥ 8.19.2, so on 10.0.12 a pin at 8.16.0 is a downgrade (NU1605) and the ecosystem would split again — Web on 8.19.2, everything else on 8.16.0. 10.0.9 is the newest patch that still ships against 8.0.1, where 8.16.0 is an upgrade. Checked before choosing: the IdentityModel notes for 8.17–8.19.2 and the ASP.NET Core 10.0.10–10.0.12 releases carry no CVE for the authentication handlers; Microsoft's bump was a routine dependency refresh. The pin has a shelf life — the next ASP.NET patch Web takes will demand ≥ 8.19.2 — and moving the whole ecosystem to 8.19.2 is the planned follow-up, host first, then Identity.

Microsoft.AspNetCore.DataProtection 9.0.4 in Core, Http and DataProtection stays: on net10.0 it resolves to the shared framework's assembly — nothing of that version is copied into the module bin or the .tpkg — so the pin only steers the net8.0 / net9.0 targets of the libraries and no 9.0.4 assembly reaches the worker.

Running redb.Identity.Web.Tests for this change found RevokedSidsPollHostedServiceTests red since 2026-07-12, when a change made the poll exit before its bootstrap call when no Identity:BackchannelClient:ClientSecret is configured — intentional: the poll is a cluster feature behind a service-account secret — but the tests built their options without one, so the service never called and the first test has failed on every run since. The second test could not fail at all: with no poll there was no exception to swallow, and every assertion still held. Nobody noticed because these tests are not part of the gate. The tests now configure the secret, wait for the poll to have been attempted (bounded at five seconds, in place of a fixed 50 ms that only ever worked by timing), and the swallow test asserts the poll actually ran. A third test pins the intentional behaviour: without the secret the service makes no call at all.

The same run showed DevTlsContractTests red: appsettings.Development.json carried "RequireHttpsMetadata": false, which the contract test forbids in committed configuration because it switches off the https requirement for the OIDC discovery document. The flag was a leftover of the http://localhost:5002 dev loop; a later change moved the dev authority to https://host.docker.internal and made the false unnecessary — the certificate side of that loop is handled by AcceptAnyBackchannelCert, a separate and explicitly dev-only switch, not by this flag. The development config now says true; the dev loop is unchanged.

The shipped appsettings.json had the same false, and that one is not a dev convenience — it is the default of every deployment, overriding the code default of true for anyone who does not set the key themselves. It now says true, and DevTlsContractTests gained a test on the shipped file so the two configs cannot drift apart again. A deployment that really talks to an http:// authority sets false explicitly for itself; the default no longer does it for everyone.

Security — a DPoP proof can no longer be consumed twice at once

RedbDpopReplayStore decided replays by lookup-then-insert, and nothing on the row was unique. Two presentations of the same proof racing past the lookup both inserted and both were told "reserved" — RFC 9449 §11.1 asks for at most once, and the store delivered it only when the presentations were far enough apart. The move to ExecuteAtomicAsync changed nothing here, and the class comment promising that "the database row uniqueness constraint prevents both inserts" described a constraint that did not exist.

The reservation now carries ValueUnique = SHA-256(jkt|jti) — the derivation the idempotency record already uses, because jti is client-chosen and unbounded while ValueUnique is capped at 440 characters. The index decides: the loser of a race gets RedbUniqueViolationException and is refused as a replay. The lookup stays as the fast path, so an already-consumed proof is refused without a failing statement, and an expired reservation is refreshed in place rather than inserted a second time — a second insert would hit the key and misreport a stale row as a replay. The core runs the insert under a savepoint, so a violation inside the transacted token route leaves the route's transaction intact. Rows written before the key existed carry none; the lookup still finds them and the cleanup timer removes them within one TTL.

Pinned by DpopReplayStoreAtomicityTests on all three providers: sixteen simultaneous presentations reserve exactly once and leave exactly one row, the plain second presentation is refused, and an expired reservation is taken again without a duplicate row. Run first against the old store, the same tests showed how wide the gap was: all sixteen presentations were accepted, not one or two — under concurrency the lookup protected nothing — and the expired path left two rows behind.

Changed — the deletion helper's dead batchSize is gone

IdentityDeletionHelper.DeleteAsync kept an int batchSize on all four overloads after the helper stopped handing the batch to the background service; it was accepted and ignored. Removed, together with the seven call sites that passed one. Purge batching is the background service's own concern; the cleanup processors keep their page sizes for the Take(...) that selects what to delete.

Comments in two stores and two other places that still described the route transaction as BeginRedbTransaction were brought up to date. The CS0618 warnings about that method in the Identity solution build come from redb.Route.Core/Xml/RedbXmlContributions.cs, which is compiled as a project reference — Identity itself no longer refers to it.

Fixed — audit tests no longer race the provider matrix

The full gate starts the sqlite, postgres and mssql suites as three processes against one machine and one set of brokers, staggered by seconds rather than isolated. Two audit suites were written as if they owned the box, and they only stayed green while the timing held.

AuditIbmMqIntegrationTests published to the same DEV.QUEUE.3 from all three processes and then took whichever message arrived first. Both halves of that race show up in the full-run logs: a run receives a sibling's message (Expected eventType to be "TestWmqFields", but "TestWmqDirect"), and the sibling, whose message was eaten, waits out its 15-second timeout. The per-test drain cannot help — it runs before the sibling publishes — and the eventType cannot tell the processes apart, because all three publish the same types. Each provider now gets a queue of its own (DEV.QUEUE.1/2/3, pre-created by the developer image), and within a process the receive accepts only its own eventType, so a late message from the previous test is skipped rather than mistaken for this test's event. Skipped messages are reported on stderr instead of vanishing quietly.

AuditCompletenessFullCycleTests waited a flat two seconds and then queried the audit catalog once. The query helper looked like a retry loop but was not one for this purpose: it polled only while the response was unsuccessful and returned an empty list the moment the endpoint answered 200 with no rows — which is the normal first answer, since the row is written by a WireTap branch that finishes after the response the caller already has. Under three concurrent suites two seconds stopped being enough. The helper now polls until the row appears, on the same ceiling, and reports how long it waited: on success, the attempt and elapsed time when the row was late; on failure, that nothing arrived after ten attempts. A future failure therefore says whether audit is slow or absent instead of leaving that to be guessed.

Neither change touches the product, and neither weakens an assertion: with the emission broken, both suites still fail. That was checked the other way round as well — with the eventType filter removed and foreign messages arriving during the receive window, the MQ test fails exactly as the gate logs show it failing.

Fixed — a database outage no longer retries forever

The DbException handler declared MaximumRedeliveries(3) next to RetryWhile(e => !IsUniqueViolation(...)), and those two do not add up the way the pairing suggests: RetryWhile does not narrow the ceiling, it replaces it. OnExceptionProcessor evaluates the predicate instead of the attempt count, inside a loop with no outer bound. A predicate that only asks "is this not a unique violation?" answers yes to every ordinary connection error, so a real outage was retried without end — and because the delay doubles each round (200 ms, 400, 800…), the route stopped answering altogether rather than returning the 503 it was supposed to return.

The predicate now carries the bound itself: it retries while the error is not a unique violation and CamelRedeliveryCounter is below the limit. Both the ceiling and the predicate read one constant, so they cannot drift apart. MaximumRedeliveries stays as the documented policy and as the bound that applies if the predicate is ever removed.

The defect was invisible until redb.Route started passing the full handler configuration through (redb.Route 4.0.1): before it, RetryWhile never reached the processor and the count check ran. So Route did not break this — it stopped hiding it. Pinned all along by ErrorHandlingPipelineTests.TransientDbErrors_AreRetried_ButABoundedNumberOfTimes, which is exactly the test that hung on all three providers.

Changed — SQL audit targets take :#name parameters

The sql: connector now writes parameters the Camel way, :#name, and no longer reads @name as one: @ reaches the database untouched, because it collides with T-SQL and MySQL variables (redb.Route 4.0.1). There is no transition period — an audit target still carrying the old syntax stops writing rows and fails with a database error instead.

Operators with a sql: audit target must update its Uri: VALUES(@event_id, …) becomes VALUES(:#event_id, …). The param.* bindings are untouched and casts still work (:#details::jsonb). Identity's own relational audit sink does not go through the connector, so only operator-configured sql: targets are affected; the suite's target and the sample in the test appsettings.json were updated here.

Changed — route transactions now ride the ambient scope

WithRedbTx used to pair .Transacted(TransactionPolicy.Suppress) with BeginRedbTransaction(name): the wrapper supplied the commit/rollback boundary while the redb transaction was opened explicitly, because the core rejected an explicit transaction whenever an ambient scope was active. redb 4.0.1 inverted that — redb enlists into the ambient TransactionScope through its AmbientConnectionRegistry — and BeginRedbTransaction is now [Obsolete]. Identity's wrapper is plain .Transacted(...), and the scope owns commit and rollback.

This changes what a processor may do inside a transacted route: redb.Context.BeginTransactionAsync() throws there, and the core points at ExecuteAtomicAsync, which joins the ambient transaction instead. RedbDpopReplayStore is the one place where that actually bit — DPoP replay reservation runs on the token route, which is transacted — and it now uses ExecuteAtomicAsync. Its old mid-logic RollbackAsync was wrong under an ambient scope for a second reason: a replayed proof must fail the request, not undo everything the route did before it.

The scope timeout is stated explicitly (2 minutes) rather than inherited: this scope spans Argon2id plus the whole OpenIddict pipeline on the token route, and the framework default of 30 seconds sits uncomfortably close to that under load.

Eleven other call sites still open their own transaction (MfaVerifyProcessor, MfaRecoveryProcessor, both WebAuthn processors and six token stores). None of them sits on a transacted route today, so none of them breaks — but any route that gains WithRedbTx later will break them at runtime with a green build. They are listed here rather than migrated silently.

Worth knowing about the test suite: no fixture configures RedbInstanceName, and without it WithRedbTx returns the route unwrapped. Route transactions are therefore inert under test and live only in a real deployment, where context.json sets the name — so this change was verified against a running instance, not by the suite.

Fixed — a deletion inside a route transaction no longer runs on a second connection

Every Identity deletion goes through IdentityDeletionHelper, and it handed the mark to IBackgroundDeletionService.DeleteAsync. That method opens a DI scope of its own — a second connection — and marks there. Under a route-level transaction (WithRedbTx, live whenever RedbInstanceName is configured, which is every Tsak-hosted deployment) the two connections fight over the same rows: on SQLite the mark waits on the writer lock the route holds and dies on the busy timeout, about 35 seconds later; on PostgreSQL and MSSQL it either blocks on rows the route has already written, or commits on its own — so a route that failed after the delete rolled back everything except the deletion. Reached through manage-scopes, manage-users, manage-tokens, manage-groups, manage-claim-mappers, manage-claim-scopes, manage-federation-providers, scim-users and scim-groups.

The mark now runs on the caller's IRedbService and joins whatever transaction it is in (SoftDeleteAsync goes through the core's ExecuteAtomicAsync). Nothing is handed to the background service and nothing needs to be: it polls for trash containers and finds the one written above by itself — its EnqueuePurge has been a no-op ever since the polling loop replaced the channel, and the batchSize it was passed never reached the mark either. When no background service is registered the warning stays: objects are marked and hidden from queries, but no physical purge will run.

Pinned by DeletionHelperTransactionTests on all three providers: the mark rolls back with the caller's transaction, commits with it, and still works with no ambient transaction at all. Proven red on the unfixed code — 35 s per test on SQLite (the writer-lock timeout), a surviving trash row on PostgreSQL. The cleanup tests that asserted the old path were rewritten rather than deleted, and their DidNotReceive assertions moved onto SoftDeleteAsync: the background service is now never called, so an assertion on it would have passed even if an object had been deleted.

Requires redb 4.0.1 or later (the trash-lock fix); the PostgreSQL and MSSQL modules it carries are applied at startup.

Changed — Microsoft.IdentityModel 8.16.0

redb.Identity.Core pins Microsoft.IdentityModel.Protocols.OpenIdConnect 8.16.0 (was 8.4.0), and redb.Identity.Http references Microsoft.IdentityModel.Abstractions, Logging and Tokens 8.16.0 directly (OpenIddict.Abstractions 6.3.0 alone resolves 8.4.0). Microsoft.Data.SqlClient 7.0.3, which redb.MSSql ships, needs 8.16.0, and the Tsak worker pins it, so the Identity packages and the host they run in ship one version. No code change.

4.0.0

Security — reserved header strip extended to the gRPC and SOAP facades

The 2026-09-09 fix removed the internal-only header names a caller must never supply on every HTTP route. The gRPC connector copies caller metadata and envelope headers into the exchange the same way, and the WS-Trust listener is HTTP underneath — neither facade stripped anything, and both reach the audited Token / Introspect / Revoke routes, so a gRPC or SOAP caller could still sign the audit log with another user's id, a fabricated address and agent, or a foreign client_id by sending them as metadata or request headers. (The session names were harmless there only because those transports never reach the authorize, logout or consent routes.) The list now lives once in IdentityReservedInboundHeaders (Contracts) and every facade strips it as its first step. operation is HTTP-only: on the gRPC envelope route the caller names the operation by design.

Audited in the same pass and found sound: /me/* and the management API carry the caller's identity in exchange properties (identity:management-*), which no transport fills from the wire; SCIM filters are parsed into a typed criteria object with whitelisted attributes and operators.

Fixed — audit attribution: client_id for form-authenticated clients, timestamps on SQLite

MapHttpToIdentityHeaders documented two sources for the client_id / client_secret headers — Basic authentication and the form body — but implemented only the first. Token issuance never noticed (OpenIddict reads client credentials from the body itself), the audit envelope did: EventDispatchProcessor attributes events by the client_id header, so every client authenticating with form credentials — public PKCE clients included — was audited with client_id NULL. The form step now exists (Basic wins when both are present; the body is parsed, not replaced).

The SQLite audit table declared timestamp TEXT and expected an RFC 3339 string, while the redb SQLite provider encodes every DateTimeOffset parameter as a Julian day number: the number landed in the TEXT column as text, the query layer failed to parse it back (every row read as 0001-01-01), and from/to filters compared text to a number. The column is REAL now, matching the provider's _date_* convention. No migration is shipped: SQLite has no conditional DDL and the table is created per file — drop existing SQLite audit files (or the table) before upgrading.

Two findings from the 2026-09-09 facade audit, both fixed.

Session identity was trusted from a request header. The HTTP consumer copies every request header into the exchange verbatim, and Identity carried the logged-in user between processors in those same headers (session_user_id, set from the decrypted session cookie by ReadSessionCookie, which returned without clearing anything when no cookie was present). AttachSessionPrincipalHandler then read session_user_id and built the principal from it. So GET /connect/authorize?... with a header session_user_id: <victim> — no cookie, no password — minted an authorization code for that user; the same header forged consent and logged other users out. A StripReservedInboundHeaders pass now runs first on every HTTP route (from PropagateCorrelationId, so no route can forget it) and removes the internal-only names a client must never supply — session_*, reauth_marked_sid, client_id, client_secret, access_token, operation, and the audit trio user_id, ip_address, user_agent — leaving only the trusted processors (cookie, Authorization header, Basic/body, request path) to populate them. In-process direct-vm callers are unaffected: the strip is at the HTTP edge, which is the untrusted boundary.

The audit trio deserves its own sentence. EventDispatchProcessor reads user_id, ip_address and user_agent from the WireTap copy of the business exchange — the same headers — and nothing trusted ever set the latter two, so the security log's address and agent columns were either empty or whatever the client typed into a header, and its user column could be pointed at someone else. For HTTP-originated events the dispatcher now falls back to the transport-owned, proxy-sanitized redbHttp.RemoteAddress and the real User-Agent, and the user id comes from the event payload.

The consent page rendered from its own query string. GET /consent showed app_name and scopes taken directly from the URL, so a link like /consent?client_id=attacker&app_name=Your%20Bank&scopes=… displayed the operator's own trusted consent UI attributing an attacker's client to a familiar name — a click on Allow then granted the attacker's client. The page now renders only from a server-signed consent ticket (?ct=…) minted by the authorization server once it has decided consent is required; app name and scopes come from the ticket, and the grant requires the ticket to have been issued for the current session's user (it cannot be replayed under another session). The console's BFF, which renders its own consent screen and posts with the session cookie, keeps working on the ticket-less, session-bound path.

The facade renders its own login, consent and MFA pages for deployments without the web console. Its consent POST read the consenting user from a hidden user_id form field and only fell back to the session cookie when the field was absent, while ReadSessionCookie let a request without any cookie continue down the pipeline. Together that meant an anonymous cross-site POST to /consent with client_id, user_id and decision=allow recorded a permanent authorization for the named user — no session, no click, no frame: the consent screen the victim would have seen for that client was silently pre-approved (and decision=deny revoked someone else's grant the same way).

The user now comes from the session cookie only. Without a session the POST is refused with 401 before anything downstream runs; the form and the redirect to /consent carry no user_id anymore.

Two further gaps closed in the same pass. The facade pages set none of the browser-protection headers the web console has always sent, so login, consent and MFA could be embedded in a frame (clickjacking on the consent "Allow" button); every response through the facade's serializer now carries X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', X-Content-Type-Options: nosniff and Referrer-Policy. And the form POSTs (/login, /consent, /mfa, /mfa/recovery) had no cross-site request check: they now refuse a request whose Origin (or, failing that, Referer) names another site, or is the opaque null, with 403 before reading the body. A request carrying neither header is let through on purpose — that is the shape of the web console's BFF (RecordConsentGrantAsync), the demo scripts and API clients, none of which a foreign page can drive; browsers always announce the origin of a cross-site form. The session cookie's SameSite=Lax default and the frame headers are the other two layers of the same defence.

Added — access tokens carry an RFC 9068 audience, and the management API checks it

Issued access tokens matched the JWT profile for OAuth 2.0 access tokens (RFC 9068) in every respect but one: they had no aud. The scope store already served ScopeProps.Resources and the admin API let an operator set them, but no sign-in handler ever attached those resources to the principal, so the claim the profile marks REQUIRED never appeared, and the local validation stack never asked for one — any token our server signed was welcome at the management API regardless of what it had been minted for.

AttachAccessTokenResources now composes the resource indicators at sign-in: the Resources of the granted scopes, joined by the application's new AccessTokenAudiences (the access-token twin of IdTokenAudiences, exposed on the application contracts and the Protocol tab of the console). The server's own audience joins whenever an identity:* scope is granted — those scopes are the server's own API — and stands alone as the §3 default resource indicator when nothing else names a resource. It is {issuer}/resources unless RedbIdentityOptions.DefaultAccessTokenAudience says otherwise. The requesting client's own client_id is always an audience as well (as with WSO2, Keycloak and Entra self-scoped tokens): once a token carries audiences OpenIddict admits only audience members to /connect/introspect and no longer falls back to the presenter, so without it a client could no longer introspect the tokens it holds. The validation stack behind the management API now requires the server's own audience (§4): a token minted for an external API only is refused with 401, not merely 403 for a missing scope.

Deployments that had set Resources on a scope will see those values steer aud for the first time; a client granted such a scope without any identity:* scope gets a token for that API alone and can no longer call the management API with it — which is what the resource assignment meant.

Introspection changes with it. Until now no access token carried aud, so OpenIddict skipped its audience check at /connect/introspect and any authenticated client could introspect any token — an oracle that let one client confirm validity and read the claims of tokens issued to another. With aud present, OpenIddict admits only audience members (RFC 7662). A client introspecting its own tokens is unaffected (its client_id is always an audience). A backend that introspects tokens issued to another client — the classic SPA + BFF pair — must now be declared an audience of them: put the backend's client_id into the SPA application's AccessTokenAudiences, or into the Resources of the scopes it serves. The integration fixture does exactly that for its public/confidential pair.

Fixed — the admin web console could not reach the identity server it sits next to

redb.Identity.Web was configured with http://localhost:5002 while the HTTP facade serves TLS on that port, so every backchannel call died with "The response ended prematurely" — discovery, login, federation providers, the revoked-SIDs poll. And the scheme alone would not have been enough: the server's issuer is https://host.docker.internal:5002 (named so that the OpenID conformance suite, which runs in Docker, can reach the host and match iss against the address it called), and an OIDC client's authority must equal the issuer. The console now uses the issuer's own name — which Docker Desktop also maps in the host's hosts file, so it resolves from the console too — plus AcceptAnyBackchannelCert in the Development profile for the bundled self-signed certificate.

Fixed — demo_scim no longer reports PASS while skipping its own body

The demo asked dynamic registration for identity:admin, which DCR deliberately never grants, then aimed at the management mount (/api/v1/identity/scim/v2) that refuses DCR-obtainable tokens. Having failed to get a token it skipped the entire CRUD body and exited 0 — green in every suite summary, including nights when SCIM writes were actually broken. It now registers with the granular scim scope, uses the /scim/v2 mount like the other SCIM demos, runs all thirteen steps, and treats a missing token as a loud failure instead of a shrug.

Changed — the contracts now ride in a carrier package of their own, and reloading one module can no longer fork them

redb.Identity.Contracts.dll used to ship inside Core.Module.tpkg. A package's hot-reload force-replaces its own companion assemblies in the worker's tracker, so reloading the core alone handed the new core a new Contracts instance while every facade kept the old one — the same CLR type in two copies, and a typed body crossing direct-vm:// quietly became null on the other side: register and SCIM writes answered "Request body is required" with nothing in the log (Tsak F-12).

The new redb.Identity.Contracts.tpkg is an empty module whose payload is its companion: the one process-wide instance of the DTO assembly. Every other Identity package excludes it and declares the dependency, so the loader's topological sort brings the carrier up first. Reloading core or any facade now leaves the contracts untouched — verified live: after a core-only hot-reload, with no facade touched, register answers 200 and SCIM create 201, the exact calls that used to break.

The deployment rule this makes explicit: redeploy the carrier only when the contracts change, and then redeploy every Identity module after it — which a contract change requires in any architecture.

Fixed — the upgrade-on-login rehash could silently undo a password change that landed after the login

The C12 upgrade-on-login rehash runs detached, about a second after the login that scheduled it: Argon2id is slow by design, Task.Run adds scheduling, the fresh DI scope adds a connection. An admin password reset fits into that window comfortably — and when the rehash landed last, it wrote the pre-login password over the freshly set one. The account silently reverted to exactly the credential the operator had just tried to retire, while the reset call had already answered success: true.

Measured on a live worker before the fix: nine resets out of nine lost when a login preceded them, three of three kept without one; every lost row still held the old hash in _users. Not provider-specific and not redb.Core's doing — pure timing inside LoginService, on any database.

The rehash is now awaited on the login path, which removes the detached writer altogether: the login answers only after the rehash is on disk, so a later reset writes last and wins by construction. A check-before-write guard alone proved insufficient live — the ~250 ms of hashing sits between the check and the write, and the reset lands inside it. The guard stays as a second layer against other concurrent changers, and the attempt is capped to once per user per process, because the upgrade does not currently converge: the write goes through the core provider's own hasher (BCrypt), so the "upgraded" hash is BCrypt again and NeedsRehash stays true forever. Convergence needs a redb.Core API for handing over a ready-made hash — reported, not worked around. Pinned by LoginRehashRaceTests, red on the unguarded code; the second test holds the other half — an untouched hash must still be upgraded.

The awaited rehash also goes through the request-scoped service rather than a fresh DI scope: the fresh scope existed only because fire-and-forget outlived the request, and once awaited it just meant a second connection — which on SQLite promptly answered database is locked against the login's own. Same connection: no second writer, and inside a transacted route the rehash joins that transaction.

Fixed — the identity core could not start in a Tsak worker, and three defects hid behind it

Deploying all four modules to a live worker surfaced what the test suite could not, because each defect needed the real hosting model to appear at all.

The core did not start. IdentitySchemaInitListener synchronises every Identity Props type by reflection, because the type is only known at runtime. When ISchemeSyncProvider.SyncSchemeAsync<T> gained its CancellationToken, that call site kept compiling without a word and threw TargetParameterCountException on the first type at startup. The loop then never ran, no scheme was synchronised at all, and every listener behind it failed with Scheme for type 'X' not found — RoleProps, ScopeProps, DataProtectionKeyProps, SigningKeyProps, ApplicationProps. One line produced a dozen unrelated-looking symptoms, and identity.core and identity.http both refused to come up. The arity is now checked once, up front, with a message naming what actually broke.

The WS-Trust facade served no WSDL. Tsak loads a module's assembly straight from its .tpkg with LoadFromStream, so the assembly never lands on disk and Assembly.Location is empty: a file resolved next to the assembly could not be found because there is no "next to". The facade therefore registered POST only and answered 405 to the GET a client generator makes first — the one thing this facade exists to serve. The contract is now an embedded resource, with the loose file still read when present. pack-tpkg.ps1 also ships content subdirectories, which it previously dropped.

Validate on a revoked token blamed the caller. OpenIddict reports a revoked token as an error rather than RFC 7662's active: false, and the facade passed that through as a wst:FailedAuthentication fault. That answer is about the caller, who had authenticated fine and asked a fair question; a revoked token is the ordinary reason to run Validate at all. Token-scoped refusals (invalid_token, invalid_grant) now answer wst:Status status/invalid with the reason, while a refusal about the caller stays a fault. The existing test asserted only "not valid", which the fault satisfied too — it is now on the shape of the answer.

Ten of ten demo steps pass against a live worker: WSDL, client registered over HTTP, token issued over SOAP, validate, cancel, validate again, wrong secret, anonymous, junk body, and a token issued over SOAP spent over HTTP.

Fixed — an unreadable X-Forwarded-For entry no longer lets the walk reach the client's part of the header

TrustedProxyResolverProcessor walked the chain right to left and skipped any entry it could not parse as an address. RFC 7239 allows a proxy to write unknown for a peer it cannot name, and some emit an IPv6 with a zone id; after such an entry the walk carried on to the left, into the entries the client supplied, and took one of them as the client. The entry now ends the walk and the socket peer is kept. Pinned by UnparseableHop_StopsTheWalk_KeepsSocketIp, red on the previous code.

The walk itself is now ForwardedHeaderResolver from redb.Route.Http.Hosting, one implementation of the trust model for the ecosystem; the processor keeps its gate (TrustForwardedFor, socket peer in KnownProxies / KnownNetworks) and delegates the rest. Its remaining role is a context whose host did not resolve; after the host did, the address it sees is the client's, which is not a trusted proxy, so it leaves it alone. Pinned by HostAlreadyResolved_PeerNotInList_IsIdempotent.

Changed — DPoP works behind a TLS-terminating proxy

RFC 9449 §4.3 has the server compare the proof's htu with the URL the client used. Behind a proxy that terminates TLS the client signs https://... and the socket sees http://..., and redbHttp.Url, which the check reads, was built from the socket. The shared HTTP host now applies X-Forwarded-Proto from a trusted proxy, so the URL is the client's. When this module has to create the host itself (no other component registered one), it hands the host the same proxies Identity:ReverseProxies names, read as configuration because this facade does not reference Core. Under a Tsak worker the host comes from the worker and Tsak:Http:TrustedProxies governs. Token_BehindTlsTerminator_HtuWithHttpsScheme_IsAccepted was red before; its control, Token_WithoutForwardedProto_HtuWithHttpsScheme_IsRejected, pins that the header is what makes the difference and the scheme check is not loose.

Added — WS-Trust facade (redb.Identity.Soap)

A third transport onto the same core routes HTTP and gRPC already use: Issue, Validate, Cancel and Renew over SOAP, for the callers whose stack builds clients from a WSDL and speaks WS-Trust rather than OAuth. Same issuer, same client registry, same token store — a client registered over HTTP gets a token over SOAP, which is the acceptance criterion the facade exists for and is covered end to end against the production OpenIddict pipeline.

One route, not four. WS-Trust puts every operation on one address and tells them apart by the WS-Addressing Action, which is what generated clients emit, so the operation is resolved by parsing rather than by routing. The endpoint counter in a worker log therefore reads 1 for this module.

The token in the RequestSecurityTokenResponse is our ordinary JWT, carried as a wsse:BinarySecurityToken. No second token format enters the system, so introspection and revocation work on it unchanged — the reasoning is recorded in doc/SOAP/OPEN_QUESTIONS.md.

TLS is mandatory and its absence is a refusal to start, not a warning. This is stricter than the other facades for a specific reason rather than a cautious one: UsernameToken carries the client secret in clear text, so an STS on plain HTTP publishes credentials to anyone on the path, and a warning in a log is read after they have already travelled. AllowPlaintext is the single escape hatch, for a connection already terminated by a trusted proxy, and it exists so an operator states that choice instead of stumbling into it. mTLS is available with modes and thumbprint pinning; an unrecognised mode is refused at start rather than read as "no certificate", because a typo must not turn mTLS off on an endpoint whose operator believes it is on.

Two gaps in redb.Route.Soap had to be closed first — the consumer could not serve TLS at all, and surfaced nothing about the connection to a route. Both are in that package's changelog.

Changed — one reading of the core's verdict, three renderings

redb.Identity.Contracts/IdentityVerdict.cs now holds what every facade was about to hold a copy of: where the core states its verdict, that the stated status outranks the error string in the body, and how that string is read. Each transport keeps its own vocabulary — a status code, a gRPC status, a SOAP fault — and renders IdentityVerdictKind into it with a switch of a few lines.

The gRPC facade is moved onto it in the same change rather than later. Left for later it would have become the third copy, which is exactly the outcome the SOAP plan set out to avoid. Its own suite passes 67/67 with no behavioural change: the tables matched down to Unknown for status codes outside the mapped bands.

The reason this matters is on record. Read from the error string alone, a rate_limited answer falls through to "malformed request" and tells a caller to fix what is not broken instead of to wait; that was found and fixed on gRPC, and the fix is now in one place rather than three.

Changed — uniqueness moved off the hand-rolled indexes onto the core's V4 primitives (V4-UNIQUE)

Verified on all three providers: 1962 of 1963 passed, the one long-standing deliberate skip, zero failures — SQLite and PostgreSQL against the usual databases, MSSQL against a fresh isolated database (which also exercised the from-scratch bootstrap: core bundle, scheme sync, backfill no-op, convergence flag). 1963 = the 1945-test pre-refactor baseline plus 17 new V4-UNIQUE tests; the hole-closing duplicate tests were proven red-before in a worktree on the pre-change code (4 + 2 failing).

The pre-V4 mechanism was IdentityUniqueIndexesInitListener: raw CREATE UNIQUE INDEX DDL at boot, eight per-scheme partial indexes on _objects guarding a value_string/_key/_name column that mirrored a [RedbIgnore] phantom property, plus Hydrate() copying the mirror back into Props on every read. It moved to the core's V4 machinery (plan and decisions: doc/v4/00-PLAN.md):

  • [RedbUnique] on the real properties — ApplicationProps.ClientId, ScopeProps.ScopeName, ClaimScopeProps.ScopeName, TokenProps.ReferenceId, FederatedIdentityProps.LinkKey (new stored composite "{provider}:{sub}"), FederationProviderProps.ProviderId, and a new UserId on MfaProps/UserProps written alongside key at every creation site (the 52 Key == queries are untouched). Lookups are GetByUniqueAsync — one probe of the core's unique index; FindByClientIdAsync runs 8–11 times per /connect/token request.
  • ValueUnique for the root-only schemes — IdentitySystemFlagProps (props empty by design; first-wins: the losing bootstrap gets its 410 from the typed violation) and IdempotencyRecordProps, whose enforced key is now the SHA-256 of the composite name (IdempotencyKeyHash) so no client-chosen key can outgrow the 440-char column; the readable composite stays in _name.
  • One typed catch — the scheme-key paths catch RedbUniqueViolationException instead of duck-typing SQLSTATE/error numbers; IsUniqueViolation survives only for the two raw-index cases below and for the route-level 409 mapping (the typed exception satisfies it through its inner chain).
  • Transition backfill (V4UniqueBackfillListener, idempotent by construction, no flag): copies the legacy mirrors into Props/ValueUnique on the first boot, reports duplicates and leaves the losers outside the index (owner decision R4a; a duplicated federated link is flagged loudly — it is a potential account-takeover), then drops the seven retired indexes — only when every scheme backfilled clean, otherwise the whole pass retries next boot. The federated-link lookup keeps a fallback to the old mirror until then: a miss there would auto-provision a second local user for an already-linked identity.
  • What stays, by owner decision: only UX_users_email on _users(_email) (R1 — V4 primitives cannot express it; note for shared databases: it imposes email uniqueness on every tenant of _users). The R2 interim index on redb.Route's idempotent-entry scheme was retired the same day: Route shipped its own fix (816a3d2a, ValueUnique + the typed catch in RedbIdempotentRepository — bug report redb.Route/docs/BUG_IDEMPOTENT_REPOSITORY_UNIQUE_RACE.md), so the index joined the retired-drop list. The LDAP sync's legacy UserProps.value_string reverse-lookup key is deliberately untouched — self-contained, never index-backed.

Migration notes: a cluster upgrades by stop-deploy, not rolling — an old node looks keys up in value_string, which the new code no longer writes. A downgrade after this version needs the mirrors re-filled (the old code cannot see new rows' keys). Identity no longer issues DDL against _objects at boot; a role without DDL rights degrades to a logged statement for the two remaining indexes and the retired-index drops, never a dead host.

Fixed — two real uniqueness holes, proven red-before

  • SQL Server never had the ClientId / ScopeName / ReferenceId indexes at all — documented trade-off of the old listener (NVARCHAR(MAX) cannot back a B-tree key), so those check-then-save races were unprotected on MSSQL. The [RedbUnique] hash column is indexable on every provider; the same duplicate tests are green on all three now.
  • Three schemes promised an index that never existed. The XML docs of FederatedIdentityProps, FederationProviderProps and ClaimScopeProps claimed a partial unique index no code created: duplicates went through on every provider — for federated links that means one external identity could be linked to two local users by a race. Verified RED in a worktree on the pre-change code (4 + 2 failing duplicate tests), green after.

Changed — admin console: pages, not dialogs (Phase 3)

The owner's rule — a rich dialog must be a page — applied to every surviving edit modal (plan: doc/webplan/PHASE-3-PAGES-NOT-DIALOGS.md). Six entities left their overlays:

  • Federation providers got what they never had at all: a detail page (/admin/federation/{id} — General / Credentials / Claim mappings / Danger zone tabs) plus /admin/federation/new. The modal could not even show Priority or the claim-mappings editor; the client secret is write-only with a show/hide toggle, and a provider still carrying the REPLACE_ME placeholder wears a NOT CONFIGURED banner explaining why it is hidden from /login.
  • Claim definitions — the console's most crowded modal (~14 fields at 640px) became /admin/claim-definitions/{id} + /new, with the enforcement semantics explained next to each control and the immutable identity (claim name / scope) rendered read-only.
  • Claim mappers — /admin/claim-mappers/{id} + /new. The page fixes the modal's blind spot: the OWNER of a rule (global / application:{id} / scope:{id}) was not selectable there, so every mapper landed global; the detail page renders the owner as a deep-linked badge to the owning application / claim scope.
  • Users — the 4-field create overlay became a two-step wizard /admin/users/new exposing the account + profile surface (the overlay showed 4 of the contract's 9 fields).
  • Scopes and claim scopes — /admin/scopes/{id} + /new, /admin/claim-scopes/{id} + /new; the claim-scope page absorbs the old inline master-detail panel — the scope's mappers are a section with deep links.

Deletes moved to the detail pages behind the typed-confirm dialog — the one dialog pattern that stays, together with the shown-once webhook secret. The phase's own review then caught the last rich dialog standing: the group-create wizard — ironically the very component the plan cited as the wizard pattern to follow — floated in a modal-backdrop itself; it is a plain card now, hosted by /admin/groups/new. What remains overlaid is deliberate and light: typed confirms, shown-once secrets, the read-only SCIM JSON viewer, a one-field WebAuthn rename, and the two-field template-confirm inside application creation. No modal-backdrop edit overlay is left in the console.

Two additions ride the same wave:

  • /admin/api-resources — the resource:action catalogue got a home: the gate's precedence rules, the full resource tree with per-scope seeded/not-seeded state (deep-linked into the scope store), and the list of operator scopes outside the model.
  • Dashboard rebuilt after the Tsak.Web composition the owner likes: a six-card KPI row (real PagedResult.Total probes), an audit-by-category donut and a clickable recent-activity table deep-linking into /admin/audit. No invented metrics: where no telemetry source exists (sparkline history) the element is simply absent.

Coverage: Phase3PagesTests pins all twelve new routes — each must exist (not 404) and must never render anonymously (not 200); it deliberately tightens the old "not 200" pattern, which a broken route template could satisfy by 404-ing.

Removed — the Hydrate mirror machinery

RedbObjectHydration and its ~30 call sites are gone: a key found through GetByUniqueAsync is a stored prop and needs no copying; the three dead sql/indexes/*.sql files (applied by nothing since the listener took over) went with it.

3.7.2

No changes of its own. The version moves with the ecosystem so the exact-version pins between packages keep resolving, and the images pick up the fixed redb.Tsak.Core and core packages from their base layer.

3.7.1

Why 3.7.1, and what happened to 3.7.0. 3.7.0 is withdrawn: it was built on .NET 9 and carries known vulnerabilities in its dependencies (see Security below). Every 3.7.0 package is unlisted on nuget.org and the v3.7.0 releases were deleted from the public mirrors. An unlisted version still installs by exact number — but there is no reason to: 3.7.1 replaces it completely.

A patch, not a minor: the public API surface does not change. Adding net10.0 to the target list breaks nothing for existing consumers, and net8.0 / net9.0 are kept.

Changed — the build moved to .NET 10

The applications and artifacts were built on net9 while the core and redb.Route had long multi-targeted net8.0;net9.0;net10.0. The gap surfaced on 3.7.0: images and archives shipped as net9.

The redb.Tsak.* and redb.Identity.* libraries now declare net8.0;net9.0;net10.0 — exactly like the core and Route, so the whole ecosystem is uniform. Host applications and tests are pinned to a single net10.0. Images, archives and tags are -net10.

.NET 8 and .NET 9 both reach end of support on 10 November 2026 — the same day, Microsoft aligned the STS 9 date with LTS 8. .NET 10 is supported until 14 November 2028. net8.0 and net9.0 remain in the libraries' target list for now.

Security — six high-severity advisories

Found while moving to .NET 10: changing the TFM forced a from-scratch rebuild and the NuGet audit spoke up. It stays silent on an incremental build, which is how all of this reached 3.7.0.

The 9.x line rather than 10.x wherever the library multi-targets down to net8.0: 10.x does not install there.

Fixed — redb.Identity.Grpc.tpkg reached no artifact at all

build-modules produces three .tpkg files, but the list of what gets copied into artifacts was hardcoded as exactly Core.Module + Http — in build-archives.ps1 (three places) and in the backend, full and managed Dockerfiles. When the gRPC facade shipped in 3.7.0 its .tpkg was built and then silently dropped: anyone installing Identity from an archive or an image got no gRPC facade at all, while the redb.Identity.Grpc package was published and announced in this changelog.

The archive script now enumerates *.tpkg from the build output and prints what it ships; the Dockerfiles copy output/*.tpkg by glob. A new module now ships because it exists, not because someone remembered to add a line.

3.7.0

Why a minor. Two new packages ship: redb.Identity.Grpc (a second facade beside HTTP) and redb.Identity.Management, the transport-neutral management controllers extracted out of redb.Identity.Http so both facades dispatch the same ones. New surface cannot be a patch.

The extraction moves types across packages. redb.Identity.Http.Controllers.* is now redb.Identity.Management.Controllers.*; redb.Identity.Http depends on the new package, so a plain package upgrade keeps working, but code naming those types by full name must be updated. The ecosystem moves on one number; redb core, redb.Route and redb.Tsak ship 3.7.0 alongside.

Changed — the packages now ship XML documentation

GenerateDocumentationFile was never enabled for redb.Identity, so all ten packages shipped a bare .dll and gave consumers no IntelliSense. It is on now, and lib/net9.0/*.xml travels with every package — 670 KB of it for redb.Identity.Core alone.

CS1591 (public member without XML doc) is suppressed, unlike redb.Route where it is deliberately left on: turning the doc file on without that adds over 3500 warnings in three of the ten projects alone, on members that predate the policy. The doc-syntax warnings stay visible on purpose, and redb.Identity.Core alone raises 218 of them — 128 unresolved cref, 100 methods missing a param tag, 6 malformed XML comments. Nothing fails to build; they are queued as a cleanup, not as a release blocker.

Added

  • redb.Identity.Grpc — a gRPC facade for redb.Identity. A second transport beside HTTP, talking to Core over the same direct-vm://identity-* routes: Token, Introspect, Revoke, UserInfo, Discovery, Jwks. Ships as its own .tpkg with ContextName: identity.grpc and zero compile-time dependency on redb.Identity.Core — the Phase 8 facade-isolation invariant, verified against the built assembly's deps.json rather than against its project file.

    One gRPC method address is one route, so every operation carries its own route id, policies, metrics and lifecycle instead of hiding inside a single dispatch switch. Ports mirror the HTTP facade (PublicPort / ManagementPort ?? PublicPort) and are never shared with it: gRPC needs HTTP/2 while the HTTP facade serves HTTP/1.1 + HTTP/2, and one listener has a single protocol set.

    Configuration lives in the shared context.json under IdentityTransport:Grpc:*, beside the HTTP facade's section; issuer and feature flags come from the same Identity:* root Core reads.

  • Published contract identity.v1.proto. Lives in redb.Identity.Contracts/Protos as a file consumers generate stubs from — the redb.Identity.Contracts package ships both .proto files under Protos/, so a non-.NET client takes the contract from NuGet rather than from the repo. Compiled only by the facade, Contracts stays a dependency-free DTO package instead of dragging Google.Protobuf into Core, Http and Client. Requests carry the parameters the RFCs name plus a string map for extensions (the wire form of these endpoints has always been form-encoded key/value); responses type what the RFCs fix and carry the rest in a google.protobuf.Struct, because userinfo claims and introspection extensions are open sets.

  • Generic envelope as a fallback path. The same six operations reachable through RedbService/Process with a JSON body and an operation header, for callers that would rather not carry the .proto. One surface, two spellings.

  • mTLS and gzip on the facade, off by default. Client certificates can be required and pinned by thumbprint; recommended for the management port in production, not forced, so deployments without a client-certificate PKI are not blocked.

  • Correlation ids and idempotency across the gRPC boundary. A caller's X-Correlation-Id is honoured (or derived from the ambient trace id) and echoed back in a trailer; each operation tags itself so Core's idempotency cache keys per operation instead of lumping every gRPC call into one bucket.

  • The management surface over gRPC. Forty admin operations across five services — Users, Applications, Groups, Scopes, Tokens — on ManagementPort, each its own method address and its own route id. The chain per operation is the security property, in this order: name the call, decode it, authenticate, halt if refused, authorize, halt if refused, and only then dispatch. Both gates run on isolated exchanges, so the halt is explicit rather than inherited from Core's exchange.Stop() — the gate stays shut until it positively sees an allow.

    The controllers are the same ones the HTTP facade dispatches, which is what redb.Identity.Management was extracted for. Propagating identity:management-* into the inner exchange comes free as a result: IdentityControllerBase.Forward already does it, so Core's self-vs-admin protection works on this transport without a line written for it.

    Contract: identity.management.v1.proto. Addresses are typed and generate into client stubs; payloads are Struct / Value. Typing the bodies of forty operations — 142 across the whole admin surface — would freeze a surface that still grows, and raw JSON would have cost the generated client.

    Self-service (/me, account registration, password recovery, MFA enrolment) is deliberately absent: it is an end-user flow reached from a browser or an app session, not admin tooling, and hosting it on an admin port would widen that port's blast radius for a caller that does not exist.

  • Split ports, verified rather than declared. ManagementPort defaults to sharing PublicPort, and until now that was the only branch ever executed — the split looked like a supported configuration without being one. It is now covered end to end: the admin addresses answer only on the management port, the protocol port returns UNIMPLEMENTED for them (which is what makes a firewall rule on the admin port worth writing), the protocol surface is unaffected, and the gate is still a gate.

  • TLS material travels through a named GrpcConnectionFactory, not in the endpoint URI. Not because the URI would disclose it — SslCertPassword is [Sensitive] and is redacted wherever the URI is rendered — but because this keeps the secret out of the route key altogether, and the route key is handled as a plain string in plenty of places.

Security

  • The management gate now requires proof that authentication ran, not merely the absence of a refusal. A deployment can wire Core without a management auth processor, which leaves direct-vm://identity-auth-management unregistered. The hop then throws "No consumer registered", Core's context-level exception handler marks that handled and replaces the body with its own error document — and the gate saw exactly what success looks like: no refusal recorded, no non-2xx code. The call still did not execute, but only because that handler happened to end the pipeline and because the wire encoder happened to refuse the leftover dictionary. Two unrelated safety nets, neither of them an authentication decision; remove either and every admin operation runs unauthenticated. The gate after the authentication hop now demands the identity:management-principal the auth processor leaves behind, and answers UNAUTHENTICATED when it is absent. Covered by a fixture that boots Core without the processor and asserts both the status and — the part that actually matters — that no row was written.
  • Idempotency over gRPC verified, and the README corrected. The claim was never tested: the key crosses two spellings (idempotency-key in metadata, Idempotency-Key in Core), and a case-sensitive dictionary anywhere on that path would have made replays create duplicates in silence. It works, and the test proves it both ways — the same key returns the original record, a different key is refused as a duplicate. The README claimed idempotency applied generally; in fact Core applies it to the management surfaces only, and the protocol operations have no idempotency layer at all. Documented where the line actually falls.
  • Per-IP protections keep working behind the gRPC facade. Core keys its per-IP throttle, its brute-force lockout and its device metadata on redbHttp.RemoteAddress, and those checks silently do nothing when the header is absent rather than failing loudly. The facade therefore bridges the real client address into it (EmitHttpCompatHeaders, on by default), and callers cannot forge it — the transport drops inbound headers carrying a reserved prefix.
  • Upstream refusals reach the caller as refusals. A status Core decided — 429 from rate limiting, 403 from the scope guard, 503 from a database outage — is translated into the matching gRPC status instead of arriving as a successful call carrying an error document that no generated client inspects. OAuth errors map by RFC 6749 §5.2, and the machine-readable code travels in a trailer because a non-OK gRPC reply has its payload discarded.
  • The facade answers even when Core stops the exchange. Several Core processors end their work with exchange.Stop() — the per-IP limiter and the granular scope guard among them — and To hands Core the facade's own exchange, so that flag ended the facade's pipeline too: the reply left as a raw dictionary that never went through status mapping or protobuf encoding. The security-relevant answers were exactly the ones losing their status. The hop into Core now goes through Enrich on a CloneLinked exchange (same exchange id, same DI scope, same properties, its own stop flag), and an unhandled fault is rethrown rather than adopted. Covered end to end: a caller over the per-IP limit is answered RESOURCE_EXHAUSTED with rate_limited and a retry-after trailer.
  • A rate-limited answer keeps its status. Core's 429 carries an error document whose code is rate_limited, which appears in no RFC 6749 table; deriving the gRPC status from the error string demoted it to INVALID_ARGUMENT and dropped the backoff advice. A status Core decided itself now wins, and the error code still travels in a trailer.

Changed

  • The granular scope table moved into Core, behind direct-vm://identity-authz-check. It used to live in the HTTP facade; a second transport was about to need the same decisions, and the failure mode of two copies of an authorization table is not that they disagree loudly but that one of them quietly grants more than the other, on the surface where that matters most. Moved verbatim: same order, same write-implies-read rule, same identity:account branch, same default-deny, same refusal wording - the HTTP negative matrix reads that wording, and rewriting expectations is how a regression net stops catching anything.

    The contract is transport-neutral: identity:authz-resource, identity:authz-action and the scopes the authentication step already established. Resource identifiers are the canonical management paths, so the table stays whole rather than being split into "paths here, scopes there". GranularScopeGuardProcessor is now a thin wrapper that names resource and action, and Core falls back to redbHttp.Path / redbHttp.Method when nothing states them, so the path is never carried twice.

    The route is registered unconditionally: a facade calling an address that is not there would fail open, which is the one direction an authorization gate must never fail.

  • Management controllers moved into their own package, redb.Identity.Management. 33 files, 142 actions, and not one line of business logic: every action validates its DTO and forwards to a direct-vm://identity-manage-* route. They lived in the HTTP facade, where a second transport could not reach them without breaking the Phase 8 isolation. Now the facades reference the package rather than each other, and the package references no Core, so that isolation still holds.

    SCIM deliberately stayed in the HTTP facade: RFC 7644 is defined over HTTP, and ScimControllerBase reads redbHttp.Url to build Location headers. Moving it would have carried HTTP semantics into a transport-neutral package for a surface that does not exist off HTTP.

    Nothing breaks for consumers: same types, only the namespace changed (redb.Identity.Http.Controllers → redb.Identity.Management.Controllers). The suite after the move is identical to the baseline before it.

Fixed

  • A duplicate is a conflict, not a database outage. The builder-level DbException handler treated every database error as transient: a unique-constraint violation was retried three times with backoff — it collides on the third attempt exactly as on the first — and then answered 503 "Database temporarily unavailable", telling the caller to come back later about something only they can fix. It now answers 409 with error: duplicate, the same vocabulary the management processors already use, while real outages keep their retries and their 503.

    Surfaces that catch unique violations themselves — applications, claim scopes, bootstrap, idempotency — were always right; users, groups, roles and SCIM fell through to the handler and were not. Found through a SCIM demo that reused one e-mail address and was reported as a database outage, which is where the hunt started instead of ending.

  • A refusal that arrived as JSON bytes no longer loses its status. MapErrorToGrpcStatus read only a dictionary body and returned immediately on bytes, without even reaching redbHttp.ResponseCode. Core's own processors hand back a dictionary, but the authentication gates and the controller dispatcher hand back serialized JSON — their refusals were reaching callers as successful calls. The response code is now always read, and the body is parsed in either shape.

  • The controller error table moved to redb.Identity.Management. It lived inside the HTTP facade; a second transport needs the same verdicts, and two copies of a refusal table drift towards "one accepts what the other rejects".

  • README and doc/DEPLOYMENT.md documented an option that does not exist, and lied about its default. The real name is UsePropsSigningKeyStore / PropsSigningKeyStore; the docs said UseEavSigningKeyStore / EavSigningKeyStore, which no type matches. The worse half: the default was documented as true while the code has false, and the verification table even labelled it "(default)". An operator running more than one replica read that a shared JWKS was on out of the box, when replicas in fact diverge on keys by default. Corrected to the fact, with an explicit "off by default, switch it on for multi-replica".

    Archived documents (doc/rewiewFix/*, doc/webplan/*, the Phase-8 plan) were deliberately left alone: they are records of finished sprints, and rewriting names inside them would falsify the record.

  • Audit DDL now reconciles an existing table instead of silently skipping it. CREATE TABLE IF NOT EXISTS is a no-op when the table is already there, so a database created before a column existed never gained it — and nothing said so. The audit query selects category and login; on such a database it failed, the route's DbException handler retried three times and answered "Database temporarily unavailable", which reads as an outage rather than a schema gap. The two CREATE INDEX statements on those columns failed on every startup for the same reason, and the init listener logged the error and continued in degraded mode.

    The PostgreSQL and MSSQL scripts now add missing columns idempotently before the indexes, and Postgres additionally repairs two types that drifted after the first release: details jsonb → text (the dialect-agnostic parameter binding passes strings, and jsonb refuses them, so every audit write failed) and user_id varchar → bigint — the latter only when every existing value is numeric, otherwise the table is left alone with a notice, because nulling unparseable identifiers is a data loss nobody agreed to. Both conversions are guarded on the current type and cost a current database nothing. SQLite cannot express conditional DDL, so its script documents the two hand-migration statements instead of pretending.

    Found on the shared test database, where the drift had accumulated: the audit query had been answering 503 there, and three AuditCompletenessFullCycleTests failed on PostgreSQL because of it.

Documentation

  • A demo for the gRPC facade, in the same run_all.ps1 gate as the other 61. demo_grpc_facade.ps1 drives ten steps against a live worker with an @grpc/grpc-js client running in a container, so the calls come from a stack that shares no code with ours: health, discovery, a client registered over HTTP getting its token over gRPC, introspection, a refusal arriving as UNAUTHENTICATED with an invalid_client trailer, and the management gate refusing without a token and admitting with one.

    Its last step closes the facade's acceptance criterion, which no test could: one token, two transports, the same verdict. A client holding identity:users:write is admitted over both; a client holding only identity:users:read is refused the write over both. In tests this was blocked because the HTTP and gRPC fixtures are separate stacks with separate registries — on a worker they are one server, so the token is literally the same one.

    The earlier decision to ship no demo was recorded as deliberate; it was not. It rested on an incomplete list of options — PowerShell cannot speak gRPC, and a .NET client against our own listener proves only self-consistency — while the container client that made it possible was already running in the interop tests at the time.

  • Package README for redb.Identity.Grpc - both surfaces, the deliberate boundaries (browser flows, DPoP, self-service), every configuration key with its default and the reason for it, message shape, the status and trailer tables a caller has to read, correlation ids and idempotency, the envelope fallback, TLS/mTLS, the forty management operations with their payload shape, and splitting the ports for production.

  • redb.Identity/README.md - the "planned" gRPC snippet replaced by the real route, the package added to the project structure, and an identity.v1.Identity section in the endpoint catalogue.

  • Interop fixture extended (C:\Work\yaml\grpc): the published identity.v1.proto and an @grpc/grpc-js client generated from it, so the facade is proven by a stack that shares no code with it. Covers a real token, a refusal read as UNAUTHENTICATED plus an invalid_client trailer, and a google.protobuf.Struct decoded by a runtime that never saw our codec.

Notes

  • Browser flows (authorize, login, consent, MFA pages, device verification) and DPoP stay on HTTP by design, not by omission: the former need a browser, redirects and a cookie session; DPoP binds its proof to an HTTP method and URL per RFC 9449. Recorded as boundaries in doc/gRPC/README.md.
  • redbHttp.Method is deliberately not synthesised by the facade. Core reads its absence as "this caller is not a browser" — synthesising it would flip authorize into emitting 302 redirects.

3.6.0

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.

Security — atomic e-mail uniqueness (register / bootstrap)

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 by IdentityUniqueIndexesInitListener alongside the existing _objects indexes. 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; _email is NVARCHAR(450) there, so it indexes on all three dialects (unlike the _value_string indexes 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 _email would treat A@x.com and a@x.com as distinct; normalising makes the stored value canonical so the index (and EmailExact lookups) enforce one identity.
  • Index violation is surfaced as 409 duplicate, not a 500: AccountRegisterProcessor catches a UX_users_email unique-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.

Removed — debug scaffolding in the register hot path

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.

3.5.1

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.

3.5.0

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.

3.4.0

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 (redb core, 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 the redb.Route 3.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.

Added

  • JWT-Secured Authorization Request — JAR (Z7 / RFC 9101), off by default. /connect/authorize now accepts a signed authorization request, gated behind Features.EnableJar (default false, so every prior release behaves identically — a request parameter is still answered with request_not_supported). When enabled:
    • a signed request object (inline) or request_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: none is never accepted — an unsigned request object drops the integrity guarantee JAR exists for; nor is a wrong-key signature, a mismatched inner client_id, an expired object, or (when the client declares RequestObjectSigningAlg under JarEnforcementMode=Enforce) a mismatched algorithm. Every failure is invalid_request_object;
    • request_uri fetches are SSRF-guarded (OutboundUrlGuard) and size/timeout-bounded; PAR's urn:ietf:params:oauth:request_uri:* is left untouched;
    • discovery advertises request_parameter_supported, request_uri_parameter_supported and request_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 RequestObjectSigningAlg etc. now do something (see below).
    • New config: JarOptions (Jar section) — enforcement mode Off/LogOnly/Enforce, allowed algorithms (asymmetric only), clock skew, size limits, and the request_uri SSRF knobs.
    • Implemented as OpenIddict server handlers (ValidateRequestObjectHandler), replacing the built-in handlers that unconditionally rejected request / request_uri. 15 handler tests + a live conformance demo (demo_jar_request_object.ps1, wired into run_all).
  • Client public-key resolver — IClientKeyResolver (Z7 / RFC 9101, phase 1). Resolves the keys that verify what a client signed: a JAR request object and a private_key_jwt assertion. Two sources — the inline ApplicationProps.JsonWebKeySet, or JwksUri, fetched and cached via ConfigurationManager<JsonWebKeySet> (background refresh on a TTL plus a rate-limited forced refresh on a kid miss, 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: ClientSecret is stored as a BCrypt hash and verifying an HMAC needs the original secret. FAPI 2.0 forbids HS* 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 JAR request_uri). Rejects anything that is not an absolute HTTPS URL, and anything resolving to a non-public address: loopback, RFC 1918, link-local — including 169.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 (ClientKeys section of RedbIdentityOptions) — 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.

Docs

  • doc/JAR_RFC9101_PLAN.md — JAR implementation plan: six phases, risks (SSRF, alg:none, algorithm substitution), a staged Off → LogOnly → Enforce rollout, and sizing. Records a verified finding: OpenIddict 6.3.0 does not support request objects — its assembly carries ValidateRequestParameter and the request_not_supported string, but nothing that parses a request object or checks its signature — and request_uri exists only as a PAR URN. Z7 now links here.
  • ApplicationProps.RequestObjectSigningAlg is now enforced under JarEnforcementMode=Enforce (was previously stored and ignored). ...EncryptionAlg / ...EncryptionEnc remain stored-only — request-object encryption (JWE) is the deferred phase 4. JwksUri is now resolved (phase 1).

Conformance impact (OpenID Foundation Basic OP)

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 advertise none in request_object_signing_alg_values_supported — which is exactly our stance: we support signed request objects only, because alg:none is 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 accepting alg:none — a security regression we will not make.

1.2.2

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.Core 1.2.1 depends on redb.Core 3.3.0, whose embedded redb_init.sql carried an ALTER FUNCTION ... OWNER TO postgres that 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 in redb.Core 3.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.

1.2.1

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.

Fixed

  • The gRPC module registered only half of itself. InitRoute added GrpcFacadeRouteBuilder and never GrpcManagementRouteBuilder, so the forty admin operations existed, were covered by their own tests, and were unreachable in any real deployment — while the same method's log line still announced a management port, which reads as configured. Every test passed because they add the builder by hand. A live Tsak worker showed identity.grpc starting with 7 endpoints instead of 47, which is how it was found; a module-level test now asserts the wiring so it cannot regress silently.

  • pack-tpkg.ps1 dropped packages into the deprecated Libs\ directory. Modules and context.json now go to Worker\modules\, which is where the worker looks. Libs\shared\ stays as the source of host-provided assemblies for the exclude set — that part was never deprecated.

  • The OpenIddict stores now enlist in the route transaction. In the .tpkg topology 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 why WithRedbTx had 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 IRedbService to the exchange's instance (a new IdentityExchangeAccessor carries the exchange in), so the stores write on the same connection the transaction was opened on. WithRedbTx is 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 in WithRedbTx and calls direct-vm://identity-token now 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 RouteTransactionMarkingTests while restoring the wrap: WithRedbTx(From(...)).RouteId(...) set the id on the transaction definition, not the route, so identity-token would 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(...) inside From(...).

Notes

  • 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 by ChildScope_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). See doc/PERF_RULES.md rule 1.
  • Test suite: Passed: 1769, Skipped: 1, Failed: 0 on PostgreSQL, MSSQL and SQLite.

1.2.0

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

⚠️ Breaking

  • Scope-derived claims are no longer embedded in the id_token. In the authorization-code flow the profile / email / phone / address claims are now delivered from /connect/userinfo only, 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/userinfo with 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 new claims parameter (below): claims={"id_token":{"email":null}}. The id_token keeps what identifies the authentication event: sub, auth_time, acr, sid, nonce, at_hash.

Added

  • OIDC Core §5.5 — the claims request 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: the userinfo member or the id_token member. essential, value and values qualifiers are honoured. A claim we hold no value for is omitted (Essential is a statement of need, not a licence to invent); a value/values constraint we cannot satisfy means the claim is omitted rather than answered with a different value. A claims request that pins sub to a different End-User is refused. Discovery now advertises claims_parameter_supported: true — it said false before, honestly. Probe: demo_claims_parameter.ps1.
  • OIDC Core §5.1 — the complete profile claim set. All 14 claims, with updated_at as a JSON number and the *_verified flags as JSON booleans, as the spec requires. UserProps gains 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. manager is a complex attribute; its $ref and displayName are 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.1 or [::1] (not the name localhost, 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 (default true — §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 the redirect_uri is 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 sending Accept: application/json or */* (curl, SDKs, the conformance suite) gets the byte-identical JSON it got before.

Fixed

  • 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=none could still surface a login form. When a claims request pinned sub to a different End-User, the rejection was deferred to the local /login page even though the RP had explicitly forbidden any interactive UI (OIDC §3.1.2.6). The error now flows back to the client's redirect_uri.
  • SCIM discovery 404 under the management prefix. /api/v1/identity/scim/v2 registered only the list endpoints — ResourceTypes/{id} and Schemas/{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_id no longer leaks into the id_token (kept on the access_token, where introspection needs it).

Changed

  • 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 claims parameter, 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.

Notes

  • Two conformance warnings are deliberate. oidcc-server finishes 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_id is the token's store-entry id, and it is what makes the id_token revocable and drives back-channel logout; redb:user_id is 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: 0 on SQLite; one codebase across PostgreSQL / MSSQL / SQLite.

1.1.0

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.

Verified

  • 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.

Added

  • 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. See HTTPS.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.

Fixed

  • Authorization error delivery (RFC 6749 §4.1.2.1). Errors are returned via the client's registered redirect_uri only — validated against the client before any redirect — closing an error open-redirect and delivering invalid_scope / invalid_request on the correct channel.
  • Token-endpoint error codes (RFC 6749 §5.2). A reused / already-redeemed authorization code now returns 400 invalid_grant (was 401 invalid_token); invalid_client maps 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_age re-authentication (OIDC §3.1.2.1). Both route the End-User back to /login and 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_verified are emitted as JSON booleans, not strings; acr as "1" / "2".
  • id_token hygiene. OpenIddict's internal oi_au_id authorization-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 full token/introspection/revocation_endpoint_auth_methods_supported sets.

1.0.1

First public source release of redb.Identity.

Verified

  • 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 the redb.Core OSS abstraction and never a concrete provider.

Changed

  • Public-repo framing (README). Documented the runnable demos/ suite and its demos/run_all.ps1 driver; 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 .tpkg packages for redb.Tsak, HTTP is merely the first transport facade, any in-process module can call Identity over direct-vm:// with zero network, and redb.Identity.Web is a reference BFF (Contracts + Client only, never Core/Http).
  • Publication tooling. scripts/publish-identity-public.ps1 prepares the public repo — copies the nine OSS source projects, the demo suite, the dev / build helper scripts and the full tests/ tree (all three test projects, so the 1767-passing multi-provider suite ships for the community to read and run), patches cross-repo redb.Core* / redb.* provider / redb.Route.* / redb.Tsak.* ProjectReferences to NuGet PackageReferences, strips the Pro redb.license and sanitises sample config, and excludes only doc/ (internal notes). A Cyrillic guard blocks the push if any non-English text slips in.