Релизы 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.
Fixed — a federation sign-in deleted the session cookie it had just issued
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.
Fixed — a Set-Cookie the caller sent came back in the response
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:manageon 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 theadminrole was refusedidentity:managejust 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 reportedcandidates=356 repaired=1 duplicates=0and 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.
Security — an HTTP header could impersonate any user, and the consent page trusted its own URL
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.
Security — the HTTP facade's consent form could be submitted for any user by anyone
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 newUserIdonMfaProps/UserPropswritten alongsidekeyat every creation site (the 52Key ==queries are untouched). Lookups areGetByUniqueAsync— one probe of the core's unique index;FindByClientIdAsyncruns 8–11 times per/connect/tokenrequest.ValueUniquefor the root-only schemes —IdentitySystemFlagProps(props empty by design; first-wins: the losing bootstrap gets its 410 from the typed violation) andIdempotencyRecordProps, 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
RedbUniqueViolationExceptioninstead of duck-typing SQLSTATE/error numbers;IsUniqueViolationsurvives 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/ValueUniqueon 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_emailon_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 inRedbIdempotentRepository— bug reportredb.Route/docs/BUG_IDEMPOTENT_REPOSITORY_UNIQUE_RACE.md), so the index joined the retired-drop list. The LDAP sync's legacyUserProps.value_stringreverse-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,FederationProviderPropsandClaimScopePropsclaimed 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 theREPLACE_MEplaceholder 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/newexposing 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— theresource:actioncatalogue 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.Totalprobes), 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.0releases 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.0to the target list breaks nothing for existing consumers, andnet8.0/net9.0are 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.
redb.Identity.DataProtection—System.Security.Cryptography.Xml9.0.4, SEVEN advisories at once (GHSA-23rf-6693-g89p, GHSA-37gx-xxp4-5rgx, GHSA-6588-8gv4-xfgh, GHSA-8q5v-6pqq-x66h, GHSA-cvvh-rhrc-wg4q, GHSA-g8r8-53c2-pm3f, GHSA-mmjf-rqrv-855v — all high). Pulled in transitively fromMicrosoft.AspNetCore.DataProtection9.0.4. An XML cryptography library, inside the product that does authentication. Pinned to 9.0.18, the patched 9.x release.redb.Identity.Http—Microsoft.Bcl.Memory9.0.0 (GHSA-73j8-2gch-69rq, high), transitively fromOpenIddict.Abstractions. Pinned to 9.0.19.
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) andredb.Identity.Management, the transport-neutral management controllers extracted out ofredb.Identity.Httpso both facades dispatch the same ones. New surface cannot be a patch.The extraction moves types across packages.
redb.Identity.Http.Controllers.*is nowredb.Identity.Management.Controllers.*;redb.Identity.Httpdepends 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;redbcore,redb.Routeandredb.Tsakship 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 samedirect-vm://identity-*routes:Token,Introspect,Revoke,UserInfo,Discovery,Jwks. Ships as its own.tpkgwithContextName: identity.grpcand zero compile-time dependency onredb.Identity.Core— the Phase 8 facade-isolation invariant, verified against the built assembly'sdeps.jsonrather 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.jsonunderIdentityTransport:Grpc:*, beside the HTTP facade's section; issuer and feature flags come from the sameIdentity:*root Core reads.Published contract
identity.v1.proto. Lives inredb.Identity.Contracts/Protosas a file consumers generate stubs from — theredb.Identity.Contractspackage ships both.protofiles underProtos/, 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 agoogle.protobuf.Struct, because userinfo claims and introspection extensions are open sets.Generic envelope as a fallback path. The same six operations reachable through
RedbService/Processwith a JSON body and anoperationheader, 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-Idis 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'sexchange.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.Managementwas extracted for. Propagatingidentity:management-*into the inner exchange comes free as a result:IdentityControllerBase.Forwardalready 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 areStruct/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.
ManagementPortdefaults to sharingPublicPort, 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 returnsUNIMPLEMENTEDfor 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 —SslCertPasswordis[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-managementunregistered. 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 theidentity:management-principalthe auth processor leaves behind, and answersUNAUTHENTICATEDwhen 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-keyin metadata,Idempotency-Keyin 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 — andTohands 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 throughEnrichon aCloneLinkedexchange (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 answeredRESOURCE_EXHAUSTEDwithrate_limitedand aretry-aftertrailer. - 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 toINVALID_ARGUMENTand 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, sameidentity:accountbranch, 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-actionand 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".GranularScopeGuardProcessoris now a thin wrapper that names resource and action, and Core falls back toredbHttp.Path/redbHttp.Methodwhen 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 adirect-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
ScimControllerBasereadsredbHttp.Urlto buildLocationheaders. 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
DbExceptionhandler 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 answered503 "Database temporarily unavailable", telling the caller to come back later about something only they can fix. It now answers409witherror: 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.
MapErrorToGrpcStatusread only a dictionary body and returned immediately on bytes, without even reachingredbHttp.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.mddocumented an option that does not exist, and lied about its default. The real name isUsePropsSigningKeyStore/PropsSigningKeyStore; the docs saidUseEavSigningKeyStore/EavSigningKeyStore, which no type matches. The worse half: the default was documented astruewhile the code hasfalse, 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 EXISTSis 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 selectscategoryandlogin; on such a database it failed, the route'sDbExceptionhandler retried three times and answered "Database temporarily unavailable", which reads as an outage rather than a schema gap. The twoCREATE INDEXstatements 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:
detailsjsonb → text (the dialect-agnostic parameter binding passes strings, and jsonb refuses them, so every audit write failed) anduser_idvarchar → 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
AuditCompletenessFullCycleTestsfailed on PostgreSQL because of it.
Documentation
A demo for the gRPC facade, in the same
run_all.ps1gate as the other 61.demo_grpc_facade.ps1drives ten steps against a live worker with an@grpc/grpc-jsclient 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 asUNAUTHENTICATEDwith aninvalid_clienttrailer, 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:writeis admitted over both; a client holding onlyidentity:users:readis 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 anidentity.v1.Identitysection in the endpoint catalogue.Interop fixture extended (
C:\Work\yaml\grpc): the publishedidentity.v1.protoand an@grpc/grpc-jsclient generated from it, so the facade is proven by a stack that shares no code with it. Covers a real token, a refusal read asUNAUTHENTICATEDplus aninvalid_clienttrailer, and agoogle.protobuf.Structdecoded 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 indoc/gRPC/README.md. redbHttp.Methodis deliberately not synthesised by the facade. Core reads its absence as "this caller is not a browser" — synthesising it would flipauthorizeinto 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 byIdentityUniqueIndexesInitListeneralongside the existing_objectsindexes. This is the atomic DB guarantee — the check-then-insert is now only a fast, deterministic early error, not the enforcement. Partial (skips null e-mails, of which there are many) so it also works on SQL Server, whose plain UNIQUE allows only one NULL;_emailisNVARCHAR(450)there, so it indexes on all three dialects (unlike the_value_stringindexes that stay Postgres-only).- E-mail normalised to lower-invariant + trimmed before the check and the insert
(
AccountRegisterProcessor,BootstrapAdminProcessor). Without it a plain index on_emailwould treatA@x.comanda@x.comas distinct; normalising makes the stored value canonical so the index (andEmailExactlookups) enforce one identity. - Index violation is surfaced as
409 duplicate, not a500:AccountRegisterProcessorcatches aUX_users_emailunique-violation (message or inner-exception) — the same shape login already used for its "already taken" 409. The loser of a register race now gets a clean duplicate error.
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 (
redbcore,redb.Route,redb.Tsak) moved together on 3.x. Running two numbering schemes made "which Identity works with which core/Route/Tsak" a lookup instead of a glance. From now on redb.Identity shares the ecosystem version: it jumps straight to 3.4.0 to sit on the same number as theredb.Route3.4.0 it is built against, and every future ecosystem release bumps Identity with it.This is a version realignment, not a semver-breaking release — there are no breaking API changes here (the only feature, JAR, is off by default and additive; see below). The major digit changes because the number is now the ecosystem's, not because Identity's contract broke. The old 1.x tags remain valid history; 3.4.0 simply continues the same package on the shared number.
Added
- JWT-Secured Authorization Request — JAR (Z7 / RFC 9101), off by default.
/connect/authorizenow accepts a signed authorization request, gated behindFeatures.EnableJar(defaultfalse, so every prior release behaves identically — arequestparameter is still answered withrequest_not_supported). When enabled:- a signed
requestobject (inline) orrequest_uri(by reference, fetched over HTTP) is verified against the client's registered keys, and the parameters inside the JWT take precedence over the query string (RFC 9101 §6.1); alg: noneis never accepted — an unsigned request object drops the integrity guarantee JAR exists for; nor is a wrong-key signature, a mismatched innerclient_id, an expired object, or (when the client declaresRequestObjectSigningAlgunderJarEnforcementMode=Enforce) a mismatched algorithm. Every failure isinvalid_request_object;request_urifetches are SSRF-guarded (OutboundUrlGuard) and size/timeout-bounded; PAR'surn:ietf:params:oauth:request_uri:*is left untouched;- discovery advertises
request_parameter_supported,request_uri_parameter_supportedandrequest_object_signing_alg_values_supported— only when JAR is on, so discovery never promises what the endpoint won't do. - The formerly write-only client fields
RequestObjectSigningAlgetc. now do something (see below). - New config:
JarOptions(Jarsection) — enforcement modeOff/LogOnly/Enforce, allowed algorithms (asymmetric only), clock skew, size limits, and therequest_uriSSRF knobs. - Implemented as OpenIddict server handlers (
ValidateRequestObjectHandler), replacing the built-in handlers that unconditionally rejectedrequest/request_uri. 15 handler tests + a live conformance demo (demo_jar_request_object.ps1, wired intorun_all).
- a signed
- Client public-key resolver —
IClientKeyResolver(Z7 / RFC 9101, phase 1). Resolves the keys that verify what a client signed: a JAR request object and aprivate_key_jwtassertion. Two sources — the inlineApplicationProps.JsonWebKeySet, orJwksUri, fetched and cached viaConfigurationManager<JsonWebKeySet>(background refresh on a TTL plus a rate-limited forced refresh on akidmiss, so a client rotating its keys does not break sign-in until the TTL expires).- Fails closed. An unreachable or malformed JWKS yields no keys, so there is nothing to verify against and the caller rejects. "Could not check" is never treated as "valid".
- A configured-but-broken inline JWKS does not fall back to
jwks_uri. A typo in the pasted key set must stay visible instead of being masked by a silent switch to another key source. - Asymmetric algorithms only. HMAC (
HS*) is impossible:ClientSecretis stored as a BCrypt hash and verifying an HMAC needs the original secret. FAPI 2.0 forbidsHS*for request objects anyway. - The service is inert for now — registered but called from no path, so server behaviour is
unchanged. Wiring it into JAR is phase 2; into
private_key_jwt, a separate step.
OutboundUrlGuard— SSRF filter for URLs that arrive from outside (jwks_uri, later the JARrequest_uri). Rejects anything that is not an absolute HTTPS URL, and anything resolving to a non-public address: loopback, RFC 1918, link-local — including169.254.169.254, the cloud metadata endpoint that hands instance credentials to whatever can reach it — CGNAT, IPv6 unique-local, and the IPv4-mapped forms (::ffff:10.0.0.1) that a naive check walks straight past. The check runs before a socket is opened. Known limitation: this does not close DNS rebinding, which would require pinning the validated address onto the connection itself.ClientKeysOptions(ClientKeyssection ofRedbIdentityOptions) — cache lifetime, minimum refresh interval, fetch timeout, maximum document size,RequireHttps,AllowPrivateNetworkTargets. The last two are development-only relaxations.- 40 tests (
tests/Security/): 29 for the SSRF guard — including one asserting that 172.32 and 172.15 are public and must NOT be blocked — and 11 for the resolver. All stay offline: attempting a network call fails the test by itself.
Docs
doc/JAR_RFC9101_PLAN.md— JAR implementation plan: six phases, risks (SSRF,alg:none, algorithm substitution), a stagedOff → LogOnly → Enforcerollout, and sizing. Records a verified finding: OpenIddict 6.3.0 does not support request objects — its assembly carriesValidateRequestParameterand therequest_not_supportedstring, but nothing that parses a request object or checks its signature — andrequest_uriexists only as a PAR URN.Z7now links here.ApplicationProps.RequestObjectSigningAlgis now enforced underJarEnforcementMode=Enforce(was previously stored and ignored)....EncryptionAlg/...EncryptionEncremain stored-only — request-object encryption (JWE) is the deferred phase 4.JwksUriis now resolved (phase 1).
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 advertisenoneinrequest_object_signing_alg_values_supported— which is exactly our stance: we support signed request objects only, becausealg:noneis an unsigned JWT that defeats JAR's integrity guarantee and is forbidden by FAPI 2.0. SKIPPED here = "the server declined an unsafe mode", the secure answer. - One module (
ensure-request-object-with-redirect-uri) moved REVIEW→SKIPPED precisely because the server now genuinely processes signed request objects and advertises that honestly, instead of the earlier no-support state that sent it down a screenshot path. Turning either skip into a pass would require acceptingalg:none— a security regression we will not make.
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.Core1.2.1 depends onredb.Core3.3.0, whose embeddedredb_init.sqlcarried anALTER FUNCTION ... OWNER TO postgresthat failed schema initialization under a non-superuser database owner — the whole first-start init rolled back. Identity is redb-backed (every store — Application, Authorization, Token, Scope, KeyRing, Session, Audit — lives in redb storage), so an Identity deployment on a least-privilege database could not start. The fix landed inredb.Core3.3.3, and without this rebuild it would never reach Identity users.1.2.2 is built against redb.Core / redb.Route / redb.Tsak 3.3.3 (was 3.3.0 / 3.3.1 / 3.3.1).
Why not 3.3.3. redb.Identity keeps its own 1.x line and does not follow the ecosystem number — but it is released together with the ecosystem, because it depends on redb storage. A fix in redb core is always a redb.Identity release too.
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.
InitRouteaddedGrpcFacadeRouteBuilderand neverGrpcManagementRouteBuilder, 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 showedidentity.grpcstarting 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.ps1dropped packages into the deprecatedLibs\directory. Modules andcontext.jsonnow go toWorker\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
.tpkgtopology Identity resolves its services from a child container, and that container used to open a host scope of its own per DI scope — a second DB connection. A route-level redb transaction runs on the connection redb.Route caches on the exchange, so the token/authorization store writes landed on a different connection: no atomicity, and — because the second connection blocked on the row locks the first one held while the first awaited the call that opened the second — a self-deadlock that cleared only on the 30-second transaction timeout (~34 s on SQLite via the busy-timeout × retry cascade). This is whyWithRedbTxhad to be stripped from the token route in 1.2.0. It was never a SQLite quirk: the deadlock rule is written about Npgsql; SQLite's single writer only made it louder.The child scope now binds its
IRedbServiceto the exchange's instance (a newIdentityExchangeAccessorcarries the exchange in), so the stores write on the same connection the transaction was opened on.WithRedbTxis restored on the token route: the token entry and the authorization entry either both land or neither does. And an addon that wraps its own route inWithRedbTxand callsdirect-vm://identity-tokennow gets one atomic commit across its own write and Identity's token issue — the claim the docs make, now real. Resolution is also lazy now: a child scope that never touches the DB no longer pays for a connection.Token route was silently losing its RouteId. Caught by
RouteTransactionMarkingTestswhile restoring the wrap:WithRedbTx(From(...)).RouteId(...)set the id on the transaction definition, not the route, soidentity-tokenwould have registered with no id — breaking cluster locks, per-route metrics and the dashboard. Every demo still passed; only the guard test saw it. Fixed by moving.RouteId(...)insideFrom(...).
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 byChildScope_BoundToExchange_ResolvesTheExchangeRedbService, which was verified to go red when the fix is disabled — a guard that cannot fail is not a guard. - The other six routes that carry no
WithRedbTx(Login, Authorize, Revoke, MfaVerify, MfaRecovery, MfaManage, ManageApps) are correctly left unwrapped and their comments now say why: a segment transaction already inside the processor (MFA), a single atomic store operation (Authorize / Revoke / apps), or a single write behind Argon2id where a route-level wrap would hold the writer lock over the CPU verify for no atomicity gain (Login). Seedoc/PERF_RULES.mdrule 1. - Test suite:
Passed: 1769, Skipped: 1, Failed: 0on PostgreSQL, MSSQL and SQLite.
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/addressclaims are now delivered from/connect/userinfoonly, per OIDC Core §5.4. This was a PII leak: an id_token is routinely forwarded to third parties and logged as proof of the authentication event, so the user's e-mail, phone and address travelled considerably further than the RP intended — and the conformance suite flags it as "may result in user data being exposed in unintended ways". If your RP read those claims out of the id_token, it will now read empty. Fetch them from/connect/userinfowith the access token (the RP loses nothing — the same claims are served there), or, if you need a specific claim in the id_token, request it explicitly via the newclaimsparameter (below):claims={"id_token":{"email":null}}. The id_token keeps what identifies the authentication event:sub,auth_time,acr,sid,nonce,at_hash.
Added
- OIDC Core §5.5 — the
claimsrequest parameter. An RP can now name the exact claims it needs instead of pulling in a whole scope to get one of them, and choose the delivery channel: theuserinfomember or theid_tokenmember.essential,valueandvaluesqualifiers are honoured. A claim we hold no value for is omitted (Essential is a statement of need, not a licence to invent); avalue/valuesconstraint we cannot satisfy means the claim is omitted rather than answered with a different value. Aclaimsrequest that pinssubto a different End-User is refused. Discovery now advertisesclaims_parameter_supported: true— it saidfalsebefore, honestly. Probe:demo_claims_parameter.ps1. - OIDC Core §5.1 — the complete
profileclaim set. All 14 claims, withupdated_atas a JSON number and the*_verifiedflags as JSON booleans, as the spec requires.UserPropsgains the missing OIDC fields — a props change, so no migration. - RFC 7643 §4.3 — SCIM Enterprise User extension.
department,manager,employeeNumber,costCenter,organization,division. This is what corporate provisioning actually sends: Okta, Entra ID and Workday push it on the very first sync, and a provider that advertises only the core schema makes them drop that data on the floor.manageris a complex attribute; its$refanddisplayNameare derived, not stored — §4.3 marks displayName read-only, and a stored copy would rot the moment the manager is renamed. Probe:demo_scim_enterprise.ps1. - RFC 8252 §7.3 — loopback redirect URIs, port not compared. Without this no
native or CLI client can use this provider at all: they ask the OS for an
ephemeral callback port at launch, so the port differs on every run and cannot
be registered in advance. The widening is exactly one port wide — it applies
only to a client that already registered a loopback URI; the host must be the
literal
127.0.0.1or[::1](not the namelocalhost, which §8.3 warns can be resolved elsewhere); userinfo in the URI is refused; and scheme, host, path, query and fragment must still match exactly. Toggle:RedbIdentityOptions.AllowLoopbackRedirectPortWildcard(defaulttrue— §7.3 is a MUST, not an opt-in). Probe:demo_loopback_redirect.ps1, deliberately mostly negative. - HTML error page at
/connect/authorize. RFC 6749 §4.1.2.1 says that when theredirect_uriis missing, unregistered or malformed the server must not redirect — it has nowhere trustworthy to send the user, so it must inform the resource owner itself. We already refused to redirect; we just informed the resource owner with a raw JSON error object. A human staring at{"error":"invalid_request"}has not been informed. Content-negotiated: browsers get the page, anything sendingAccept: application/jsonor*/*(curl, SDKs, the conformance suite) gets the byte-identical JSON it got before.
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=nonecould still surface a login form. When aclaimsrequest pinnedsubto a different End-User, the rejection was deferred to the local/loginpage even though the RP had explicitly forbidden any interactive UI (OIDC §3.1.2.6). The error now flows back to the client'sredirect_uri.- SCIM discovery 404 under the management prefix.
/api/v1/identity/scim/v2registered only the list endpoints —ResourceTypes/{id}andSchemas/{id}were missing. A provisioning client is pointed at one base URL, walks ResourceTypes and then fetches each Schema by id — and got a 404 on the first schema it asked for. oi_au_idno longer leaks into the id_token (kept on the access_token, where introspection needs it).
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
claimsparameter, SCIM Enterprise, loopback). Two were struck, with the reasoning stated plainly:- Front-Channel Logout 1.0 — it signs RPs out through an iframe to each
client, so it rides on third-party cookies. Safari's ITP blocks them; Chrome
is burying them. The mechanism is broken by design in a modern browser. We
ship Back-Channel Logout — server to server, signed logout token, plus a
pull feed of revoked
sids for multi-replica RPs. It is strictly better and cookie-independent. The working mechanism beats the checkbox. - HOTP (RFC 4226) as a standalone method — counter-based OTP is effectively unused; the world runs on TOTP. RFC 4226 lives on as the foundation of our TOTP (160-bit secret per §4), which is what we now say.
- Front-Channel Logout 1.0 — it signs RPs out through an iframe to each
client, so it rides on third-party cookies. Safari's ITP blocks them; Chrome
is burying them. The mechanism is broken by design in a modern browser. We
ship Back-Channel Logout — server to server, signed logout token, plus a
pull feed of revoked
Notes
- Two conformance warnings are deliberate.
oidcc-serverfinishes WARNING on two non-requested id_token claims. These are warnings, not failures — OIDC Core does not forbid extra id_token claims, and the suite's own message concedes it may be seeing "an extension the conformance suite is not aware of". Neither is data about the user:oi_tkn_idis the token's store-entry id, and it is what makes the id_token revocable and drives back-channel logout;redb:user_idis a namespaced private claim (RFC 7519 §4.3) that lets a client join our internal bigint to its own user table without a round-trip. In the same run we deleted a third such claim (oi_au_id), because it was OpenIddict's internal authorization link, meaningless to a client, and there was nothing to defend. Keeping two and cutting one is a judgement, not a rationalisation. - Test suite:
Passed: 1767, Skipped: 1, Failed: 0on SQLite; one codebase across PostgreSQL / MSSQL / SQLite.
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. SeeHTTPS.md. OPENID_CERTIFICATION.md— what redb.Identity implements toward OpenID Certification and the current conformance state.- Configurable PKCE (
RedbIdentityOptions.RequirePkce) — enforce proof-key per client, or relax for a non-PKCE Basic-profile run; S256-only when present.
Fixed
- Authorization error delivery (RFC 6749 §4.1.2.1). Errors are returned via
the client's registered
redirect_urionly — validated against the client before any redirect — closing an error open-redirect and deliveringinvalid_scope/invalid_requeston the correct channel. - Token-endpoint error codes (RFC 6749 §5.2). A reused / already-redeemed
authorization code now returns
400 invalid_grant(was401 invalid_token);invalid_clientmaps to 401. Cache-Control: no-store+Pragma: no-cache(RFC 6749 §5.1) on the token, PAR, introspection, revocation and device-authorization responses; discovery and JWKS stay cacheable by design.prompt=login/max_agere-authentication (OIDC §3.1.2.1). Both route the End-User back to/loginand complete on re-login — never leaked as an error to the RP. The signed re-auth marker is bound to the session id, so there is no redirect loop and no same-second bypass.- Boolean claims (OIDC §5.1).
email_verified/phone_number_verifiedare emitted as JSON booleans, not strings;acras"1"/"2". - id_token hygiene. OpenIddict's internal
oi_au_idauthorization-reference claim no longer leaks into the id_token (kept on the access_token for introspection linkage). - Discovery metadata completeness —
acr_values_supported,claim_types_supported, and the fulltoken/introspection/revocation_endpoint_auth_methods_supportedsets.
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 theredb.CoreOSS abstraction and never a concrete provider.
Changed
- Public-repo framing (README). Documented the runnable
demos/suite and itsdemos/run_all.ps1driver; listed the SQLite provider alongside PostgreSQL / MSSQL; reconciled the RFC catalogue against the source tree; and made the deployment story explicit up front — the engine ships as.tpkgpackages for redb.Tsak, HTTP is merely the first transport facade, any in-process module can call Identity overdirect-vm://with zero network, andredb.Identity.Webis a reference BFF (Contracts + Client only, never Core/Http). - Publication tooling.
scripts/publish-identity-public.ps1prepares the public repo — copies the nine OSS source projects, the demo suite, the dev / build helper scripts and the fulltests/tree (all three test projects, so the 1767-passing multi-provider suite ships for the community to read and run), patches cross-reporedb.Core*/redb.*provider /redb.Route.*/redb.Tsak.*ProjectReferences to NuGet PackageReferences, strips the Proredb.licenseand sanitises sample config, and excludes onlydoc/(internal notes). A Cyrillic guard blocks the push if any non-English text slips in.