Релизы redb.Identity
Подписаться на релизыВсе релизы redb.Identity, от свежего к старому. Текст — как в репозитории, без пересказа.
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.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.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.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.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.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.
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.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.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.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.
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.