Duende IdentityServer 8.0.1 — parity matrix
What this server has, against what Duende IdentityServer 8.0.1 has. Tracked by #40.
Why this document exists. PRs #37 and #38 were labelled a "Duende 8 parity sweep". They were not. They used one consuming host's compilation as the oracle — fix whatever that host could not build against — which finds every API it touches and, by construction, nothing it does not. This file is the comparison that was never run.
Method. Duende's side is read from lib/net10.0/Duende.IdentityServer.xml in the shipped
Duende.IdentityServer.8.0.1.nupkg. That documents only documented public types, so every Duende
count here is a floor. Our side is read from public declarations under src/. Where a row says
"absent", it was checked by grep for the type or a call site, not inferred from the counts.
For §1 the check is mechanical rather than by inspection: enumerate every public property and
constant declared under src/, then count read sites for each, excluding its own declaration and doc
comment. Anything with zero reads is a candidate, and each candidate is then triaged by hand into a
false capability promise, a defect, or a harmless unused constant. This is the step that separates
this document from PRs #37 and #38 — reading type names finds what you already suspect, and a
member nobody remembers adding is exactly the one no reader thinks to grep for. Most of §1 was
invisible to the first pass of this very document.
Status vocabulary.
| Status | Meaning |
|---|---|
| ✅ | present and exercised by tests |
| 🟡 | present but narrower than Duende's, or untested |
| 🔴 | advertised but absent — a constant, enum member, property or option promises it, code compiles, and either nothing happens or something narrower than the name happens |
| ⬜ | absent, and honestly absent |
| — | deliberate non-goal |
🔴 is the most urgent category. It is not a missing feature; it is a false statement the compiler cannot catch. The thirteenth interaction-service member fixed in #38 was this shape, and it reached a consumer before anyone noticed.
The first pass of this document said there were eight. A systematic sweep found more. The
original eight were found by reading type names and then grepping; the sweep enumerates every
public member declared under src/ and counts read sites for each, which is the only way to find
the ones nobody thought to look for. It surfaced a second, worse class — options that are read, but
only to decide what the discovery document says — and twelve further rows of never-read
configuration in the same shape as FrontChannelLogoutUri. Of the twenty-four rows in §1, fifteen
were invisible to the first pass.
All twenty-four are now closed — two of them by deletion under OAuth 2.1, the rest implemented:
device flow, mTLS, azp, private_key_jwt, JAR and the check-session endpoint (§1a), all three
endpoint-toggle rows (§1b), and the §1c rows — the static audience, the per-resource signing
algorithms, remembered consent, the client identity on the consent page, the CORS origins, both
back-channel logout rows, ProtocolType, the max_age/prompt=login/auth_time trio, and the
client claims. AccessTokenJwtType (§10) is implemented alongside them.
And then the sweep was run in the other direction, and §1e opened seven more rows. Twenty-four
closed was never the same as swept: §1a–§1d count read sites, so every row in them is a member
nothing consults. The mirror — a member that is read, and that nothing ever produces — needs a
different query, and running it over all 453 settable public members found seven, one of them in
the signing-key path. Closed is a property of a row, not of the document.
Closed means the member no longer lies about what it does. It does not mean this server matches Duende feature for feature: the 🟡 rows in the sections below were narrower (the last closed at 0.161.0), and §1's own closures include two that are narrower on purpose — mTLS aliases are path-based with no subdomain form, and the client-claim defaults are chosen here rather than read from Duende. Both say so where they are configured.
With the check-session iframe in, all three session-notification paths exist: front-channel (browser iframes), back-channel (server to server), and polling (Session Management). The first two fire only on a deliberate signout here; the third is how a relying party notices a session that ended any other way.
On what "tested" means for mTLS. The certificate is placed on the connection by test middleware, because the test host speaks no TLS. Everything above the transport is covered; the handshake itself is not, and only a real TLS listener can cover it. Recorded here rather than left for someone to assume either way.
Assume any capability here is absent until a call site proves otherwise. EnableCheckSessionEndpoint
and EnableJwtRequestUri were each read at zero sites when this document was written, and
EnableCheckSessionEndpoint defaulted to true — a host had every reason to believe it was on.
Both are implemented now.
A read site is not a reachable one.
EnableJwtRequestUrigained a call site and the by-reference path still could not run: the authorize endpoint's PAR resolver rejected everyrequest_uriwithout its URN prefix, so a JAR URL was refused before the request-object resolver saw it. The test that should have caught it asserted a refusal — which is what the broken path produced too, for an entirely different reason. A test whose expected outcome is "refused" proves nothing about why.
A member found by building on it, not by the sweep.
SessionIdis carried onAuthorizationCode,RefreshToken,DeviceCodeand the logout context, and nothing ever set it: nosidwas issued at sign-in, so every one of them was always null. The sweep could not see this — each of those members is read, by the code that copies it from one place to another. Only using the value for something exposed that it never had one. Now issued byIUserSessionon first use — and, since 0.174.0, at sign-in.
On
Clientproperties specifically.DapperClientStorepersists aClientas a whole-object JSON blob, so every property below round-trips through storage intact. Round-tripping is not honouring: the value comes back exactly as it was set, and no code ever reads it to act on. This is what makes the class hard to spot by inspection — nothing is obviously dropped.
1. Advertised but absent — implement first
Resolution policy: implement. Deleting the advertisement is not on the table. An earlier draft of this document offered deletion as a legitimate outcome for §1a and §1c, on the reasoning that silence is safer than a false claim. That reasoning holds only for a server that is not chasing parity. This one is: the goal is to match Duende 8.0.1 as a product, and Duende implements every row below. Cutting the constants would make the false advertisement go away by making the gap permanent, and would score as progress on the count while moving the actual target further away.
So §1 is not a delete list. It is the part of the parity backlog where a host can already write the configuration, which makes each row both a correctness bug today and a feature owed tomorrow — the best-specified place to start, because the intended API is already in the tree.
1a. Capabilities with no implementation behind them
| What | The promise | The reality |
|---|---|---|
| ✅ Device flow (RFC 8628) | Models/GrantTypes.cs:27 exposes GrantTypes.DeviceFlow |
Implemented end to end: device authorization endpoint, IDeviceFlowStore with in-memory and Dapper implementations, user code generation, the device_code grant with authorization_pending / slow_down / access_denied / expired_token, IDeviceFlowThrottlingService, an approval page, expiry sweeping, and both discovery entries. As of 0.23.0 the approval page shows the scope metadata a host configured and lets an optional scope be declined, which it did not until then — see §1e row 2. |
✅ private_key_jwt (RFC 7523) |
IdentityServerConstants.SecretTypes.JsonWebKey |
Implemented. JwtBearerClientAssertionSecretParser reads the assertion and PrivateKeyJwtSecretValidator checks it against the client's JWK secrets, pinning issuer, subject, audience and lifetime. Available at every endpoint that authenticates a client, and advertised in discovery. As of 0.135.0 a certificate registers too — SecretTypes.X509CertificateBase64, Duende's, read by ClientExtensions.GetKeysAsync as an X509SecurityKey under its thumbprint, ahead of the JWKs, for assertions and request objects alike; an expired certificate verifies nothing. Ours skips a secret that does not read, where Duende's refuses the client |
| ✅ mTLS client authentication | IdentityServerConstants.SecretTypes.X509CertificateThumbprint |
Implemented (RFC 8705). Both registration rules: X509ThumbprintSecretValidator matches the SHA-256 thumbprint (self_signed_tls_client_auth) and X509NameSecretValidator matches the subject distinguished name whole and case-insensitively (tls_client_auth), which authenticates only because the listener has already rejected certificates that do not chain to a trusted CA — its doc comment says so. Tokens are certificate-bound: SecretValidationResult.Confirmation was carried from validator to token request and read by nothing, so no token ever carried cnf; it now becomes cnf/x5t#S256 on the access token, and introspection returns it as an object rather than as a string of JSON. And as of 0.45.0 it is compared rather than only published: a certificate-bound token presented to this server's own resource endpoints is checked against the certificate on the connection (RFC 8705 §3). The same member has now been the subject of both §1e shapes in turn — written by nothing, then written and read by nothing — which is what a value that only ever travels outward looks like. The endpoints clients authenticate at are also served under connect/mtls/ behind MutualTls.Enabled, advertised as mtls_endpoint_aliases from the same list the router matches. Narrower than Duende in one way, deliberately: aliases are path-based only, and nothing here names a domain to promise the subdomain form. As of 0.135.0 an alias asks MutualTlsOptions.ClientCertificateAuthenticationScheme first (Certificate by default), Duende's MutualTlsEndpointMiddleware, measured: a certificate the host's handler does not accept is invalid_client / "mTLS authentication failed." before any endpoint runs. Only the aliases are asked, where Duende asks under the whole connect/mtls prefix and rewrites it — serving authorize there; a scheme with no handler is an exception naming the option, where Duende's is ASP.NET Core's. Not taken: DomainName (the subdomain form, above), and the backchannel-authentication alias Duende does not have stays |
✅ EnableCheckSessionEndpoint |
Option exists and defaults to true |
Implemented (Session Management 1.0): the check-session iframe is served at connect/checksession, session_state is returned on the authorization response, the browser-state cookie is issued and cleared on signout, and check_session_iframe is advertised. The option now gates all of it. |
✅ EnableJwtRequestUri |
Option exists | Implemented (JAR, RFC 9101). Request objects by value in request are always accepted; the request_uri fetch is what this option gates, and it defaults to off because it is an outbound request to a client-chosen location. Told apart from PAR's request_uri by PAR's registered URN prefix — in both resolvers, since the one at the authorize endpoint originally read "not a reference I know" as invalid and made the by-reference half unreachable. Resolved in the authorize endpoint and in the interaction service, so the login and consent pages describe the signed request rather than the query string. A fetched object is then stored under a reference and the browser redirected back carrying it, so the client's URL is fetched once per authorization request rather than once per render — that is both the difference between one outbound request and about five, and what stops a URL that answers differently each time from having a user consent to one request and the client granted another. 0.177.0 (security): the client is resolved before the fetch, as Duende's validator loads it first (measured), so an unknown or disabled client is unauthorized_client with nothing fetched, and a request_uri over 512 characters is invalid_request_uri unfetched (Duende's bound); a return URL carrying a JAR request_uri is not resumed by the interaction service, which until then fetched for any visitor to the sign-in page. The stored request is marked and validated on its return as Authorize, as Duende's — fetching again on every pass — validates it; until then it read as pushed and got AllowUnregisteredPushedRedirectUris. Parameter assembly stays OpenID Connect Core §6.3.3's and Duende's (a parameter the object does not carry is read from the query); RFC 9101 §6.3's stricter rule is recorded, not applied. |
— password grant (ROPC) |
(was: GrantTypes.ResourceOwnerPassword{,AndClientCredentials}) |
Deliberate non-goal, constants deleted. Removed by OAuth 2.1. It was never serviceable — the token validator had no branch for it — so a client registered for it validated cleanly and could never obtain a token. Deleting the name makes that a compile error at configuration time instead of a runtime puzzle. |
— implicit grant |
(was: GrantTypes.Implicit{,AndClientCredentials}, plus the IdToken/CodeIdToken/CodeToken/CodeIdTokenToken response types and the FormPost/Fragment response modes) |
Deliberate non-goal, constants deleted. Removed by OAuth 2.1. The authorize validator rejects every non-code response type, so none of it was ever serviceable. Discovery also advertised fragment and form_post as supported response modes — a false statement in the protocol surface itself, then corrected to query alone. Corrected again in 0.130.0: the two response modes were never implicit's and hybrid's alone; they are back for code — see the implicit row. |
✅ Client.FrontChannelLogoutUri |
Settable property at Models/Client.cs:57 |
Implemented. The session records the clients it authenticates, and the signed-out page loads each registered URI in a sandboxed hidden iframe with iss, and sid for a client that asked for it. Duende assembles the frames behind a single SignOutIFrameUrl; the difference is where they are assembled, not what the browser loads. |
1b. Endpoint toggles that hid an endpoint instead of disabling it — ✅ fixed
This was the most dangerous row in the document. EndpointsOptions looked like a switchboard and
was not: every endpoint was registered unconditionally and EndpointRouter.Find matched on path
alone, consulting no options. The toggles were read in exactly one place —
DiscoveryResponseGenerator.cs:53-87 — where they decide what the discovery document advertises. So
| Option | What a host expected | What happened |
|---|---|---|
EnableTokenEndpoint, EnableUserInfoEndpoint, EnableIntrospectionEndpoint, EnableTokenRevocationEndpoint, EnableEndSessionEndpoint, EnablePushedAuthorizationEndpoint |
false turns the endpoint off |
Removed from discovery, still served every request |
EnableAuthorizeEndpoint |
false turns authorize off |
Skipped the startup scheme check and the discovery entry; authorize still served |
EnableDiscoveryEndpoint |
false turns discovery off |
Read at zero call sites — discovery could not be turned off at all |
A host disabling introspection or revocation to shrink its attack surface ended up strictly worse off than if it had left them alone: the endpoint still answered, and it was now unadvertised, so it was also unmonitored and absent from any inventory built from discovery.
Now: EndpointRouter.Find consults IsEndpointEnabled, so a disabled endpoint falls through the
middleware and is not served. The options are read per request rather than captured in the
constructor — the router is a singleton registered during AddIdentityServer(), so reading them at
construction would have frozen the defaults and ignored any later Configure<IdentityServerOptions>,
which is the same defect one layer down. JWKS follows EnableDiscoveryEndpoint rather than carrying
its own flag — Ours, recorded at 0.165.0 (plan row 41): Duende's IsEndpointEnabled has no case for its Jwks, so its key
set is served whatever the switches say and refused only by DiscoveryOptions.ShowKeySet. Tracked by #43.
1c. Never-read configuration — the FrontChannelLogoutUri shape, eleven more times
Each of these is settable, round-trips through storage, and is read by nothing.
| Member | Promise implied | Note |
|---|---|---|
✅ Client.BackChannelLogoutUri |
Back-channel logout notification | Implemented (§5). The session's clients are notified server to server with a signed logout token, after the local sign-out and without the browser. |
✅ Client.FrontChannelLogoutSessionRequired |
sid in the logout request |
Implemented: honoured when building the notification URL. A client that did not ask is not told, since the session identifier correlates that user across every client in the session. |
✅ Client.BackChannelLogoutSessionRequired |
sid in the logout token |
Implemented, and honoured: a client that did not ask is not told, since the session identifier correlates that user across every client in the session. Note it defaults to true, unlike its front-channel twin. |
✅ Client.AllowedCorsOrigins |
Browser clients may call the token endpoint cross-origin | Implemented (§6). InMemoryCorsPolicyService reads the origins off the enabled clients, and an ICorsPolicyProvider applies them to the protocol paths only. |
✅ Client.AllowRememberConsent |
"Remember my decision" on the consent page | Implemented, both halves: the consent page offers the choice (pre-ticked) and the client's setting vetoes it, so a posted value cannot buy a durable grant the client forbids. A client that forbids it is not shown the control at all. |
✅ Client.AlwaysSendClientClaims, Client.ClientClaimsPrefix |
Client claims on machine tokens, under a name of their own | Implemented, and it was a defect, not merely an unread flag: DefaultClaimsService copied Client.Claims onto every access token, user-delegated ones included, unprefixed — so a client claim named role was indistinguishable from the user's and an API authorizing on role could not tell which it was reading. Client claims now go on machine tokens always (there is no user there to confuse them with) and on user-delegated tokens only when the client asks, prefixed client_ either way. Both defaults are chosen on the safe side rather than read from Duende — the shipped assembly carries no method bodies (see below) — and ClientClaimsPrefix can be set empty to restore the old unprefixed shape as a deliberate act. |
✅ Client.ClientUri, Client.LogoUri |
Client identity shown on the consent page | Implemented. The logo renders and the name links to the client URI, as an outbound link with rel="noopener noreferrer" — the URI is client-controlled, so it gets no referrer and no window handle back to the consent page. |
✅ Client.ProtocolType |
Selects the protocol for the client | Implemented. A client registered for another protocol is refused at both the authorize endpoint and client authentication, rather than served as though it had been registered for OpenID Connect. IdentityServerConstants.ProtocolTypes is now the one definition; ErrorMessage.Protocols had been a second copy of the same literals while documenting itself as the same token. 0.157.0 — Duende's saml2p (re-measure decision 3): ProtocolTypes.Saml is saml2p, with Saml2p under Duende's name, which a SAML provider's IConnectedApplication.ProtocolType answers, as Duende's does; saml until then. An ErrorMessage stored with saml is read as saml2p; a client stored with either is refused, as before; nothing else persisted holds one. Breaking. |
✅ max_age, prompt=login, JwtClaimTypes.AuthenticationTime |
A client can require a freshly authenticated user | Implemented. All three were the same gap: auth_time was copied onto tokens by IClaimsService and never set by anything, so max_age had nothing to compare against and was ignored; prompt was surfaced to the UI but the authorize endpoint never acted on login. A relying party asking for re-authentication was answered as though the user had just re-authenticated. Both prompt=login and max_age are consumed on the return URL: max_age=0 and an unparseable value are not self-satisfying the way a positive one is, so without consuming them the user would never get past the login page. As of 0.142.0 they are suppressed rather than consumed, Duende 8.0.1's way, measured: the return URL keeps the client's prompt and max_age and adds suppressed_prompt / suppressed_max_age, which the validator subtracts — and laid over a pushed request's parameters too, where stripping the query string had removed nothing, so a pushed prompt=login or max_age=0 asked for the login page on every return (found doing it). max_age that is not a non-negative 32-bit integer is refused (invalid_request, "Invalid max_age") where it used to re-authenticate. |
✅ ApiResource.AllowedAccessTokenSigningAlgorithms |
Per-resource signing algorithm restriction | Implemented. Intersected across the API resources in the token and passed to the key material service. Disjoint requirements throw rather than silently picking one, since either choice yields a token the other resource cannot validate. |
✅ IdentityServerConstants.AccessTokenAudience ("{0}resources") |
The static {issuer}/resources audience |
Implemented as EmitStaticAudienceClaim, defaulting to false as Duende's does (§10). Additive, so the API audiences still travel. |
✅ JwtClaimTypes.AuthorizedParty (azp) |
azp on issued tokens |
Implemented on the id_token. The audience is always the one client, so OIDC Core §2 only permits azp here rather than requiring it — which is why nothing missed it. Emitted anyway: a relying party reading azp should not have to know this server never issues a multi-audience token. |
✅ AuthorizationRequest.DisplayMode, AuthorizationRequest.RequestObjectValues |
display honoured by the UI; request-object values surfaced to it |
All populated. DisplayMode and UiLocales from display and ui_locales; RequestObjectValues from the verified JAR object, which is what it was always for and which needed JAR to exist first. As of 0.164.0 from the validated request, as Duende's parser builds it: DisplayMode only page, popup, touch or wap, and Parameters filled (it was declared and left empty). |
1d. SAML: four values parsed and then dropped — ✅ fixed
Found by running §1's read-site sweep over SamlConstants, which had never been swept. Full
reasoning and the ranking are in §11; the rows are here because §1 is where a promise a host can
already write lives.
| What | The promise | The reality |
|---|---|---|
✅ AuthnRequest/@ForceAuthn |
An SP demands fresh authentication before a sensitive operation | Honoured. Consulted before the session is, since a session that exists is what it says not to rely on. The demand is inside signed XML and cannot be stripped from the return URL the way prompt=login is, so the forcing is recorded against the request id and consumed on the way back — one re-authentication per request, and no loop. The record lives in ISamlRequestStore, which was registered in DI and called by nothing; its one-time-consume contract was already the right shape |
✅ NameIDPolicy/@Format |
Issue the assertion with the NameID format the SP asked for | Honoured, or refused by name. As of 0.134.0, Duende's: the requested format, else the registration's DefaultNameIdFormat, must be one of SamlOptions.SupportedNameIdFormats, or the request is refused with InvalidNameIDPolicy before the user is sent to sign in; the format asked for is the format issued, unspecified included. Until 0.134.0 only the registration's one format could be asked for, the check came after sign-in, and unspecified was answered with the registration's format |
✅ @Version |
Only SAML 2.0 is accepted — SamlConstants.Version says so |
Compared on both sides, and no longer substituted. The claim that it was never checked was wrong and is dissected in §11. What was real is fixed: the reader refuses a message that omits the schema-required attribute instead of defaulting it, and the IdP side compares it the way the SP side always did. Both now have tests, which they never had. As of 0.133.0 a signed LogoutRequest's is compared too — it was not even read, the model having no field for it |
✅ Namespaces.XmlEnc |
The XML Encryption namespace, alongside four namespaces we do use | Implemented. An <EncryptedAssertion> is opened before the response is read — the assertion's signature is inside the ciphertext, so there is nothing to verify until it has run — and the plaintext replaces the envelope, so every wrapping defence in SamlSignature then applies to what actually came out. RSA-OAEP only for the content key (RSA-1.5 is what Bleichenbacher targets, and this endpoint is a decryption oracle by construction), AES-128/192/256 for content, key length checked against the algorithm. Reachable from a host through SamlSpOptions.DecryptionCertificate and RequireEncryptedAssertion |
1e. The completed write-site sweep — read but never produced, and produced but never read
The sweep behind §1a–§1d counts read sites. It is blind by construction to the mirror defect: a
member that is read — by code faithfully copying it onward — and that nothing ever sets. Six had
been found one at a time (SessionId, jti, auth_time, structured JSON claim values,
SecretValidationResult.Confirmation, PersistedGrant.ConsumedTime); the systematic run was
outstanding. It has now been run to completion over all 453 settable public members declared
under src/, in both directions, and the §1b question — is this option read anywhere but the
discovery document — has been put to every option group.
§1b is clean. The only options whose sole read site is DiscoveryResponseGenerator are
DiscoveryOptions.Show*, which is what those options are for. No group repeats the EndpointsOptions
defect.
The sweep found seven rows. None is a missing Duende feature discovered by comparison; every one is a promise this tree already makes to a host and does not keep. Row 4 is closed — fixed in 0.12.0, the same release that recorded the seven. Rows 3 and 5 are closed too, decided in 0.14.0 and both by deleting the member; the argument for each is under the table, because a deletion is the one outcome that leaves nothing behind to read. Row 1 closed in 0.15.0, the largest of the eight and the only one that needed a capability built rather than a value wired. Row 2 closed in 0.16.0, which was the other one: reading six members that describe a per-scope control meant building the control they describe. Row 8 was added afterwards and is also closed: it is this shape produced in the release that catalogued it, found while implementing the Dapper session store rather than by any sweep, and fixed in 0.13.0. Row 6 closed in 0.22.0, and closing it took more than wiring a read: the store the member lived on did nothing at all, so the fix was to give it the job the endpoint had been handing to the browser. Rows 9, 10 and 11 were added later still, each closed in the release that found it — 0.44.0, 0.45.0 and 0.49.0 — and none of the three by re-running the sweep. Every one was found by building something that had to use the thing. That is the pattern worth reading off this table now: the sweep found what it was shaped to find in one pass, and everything since has come from a consumer, which is method note 18 in a sentence. Row 12 is the first that came from neither — it is the rules of rows 9–11 run backwards over the eight releases that shipped after them, and it found row 9's own shape again, which is why it is here rather than in a commit message.
| # | Member(s) | The promise | The reality |
|---|---|---|---|
| 1 | ✅ KeyManagementOptions.Enabled |
"Whether automatic key management is enabled. Defaults to true." |
Fixed in 0.15.0 (#60). Read at zero sites: setting it to false changed nothing, and there was nothing to change to — no AddSigningCredential, no ISigningCredentialStore/IValidationKeysStore, so no host could supply a static signing key at all. EnableCheckSessionEndpoint's shape, in the signing-key path. Now KeyMaterialService reads it, and it is the only consumer of IKeyManager, so false stops generation, storage and rotation outright. The two modes are asymmetric on purpose: on, the managed key signs and a static key is accepted for validation only, so a host migrating keeps verifying tokens signed by the key it is leaving; off, the static credential signs. The third outcome — asking for your own key and silently getting a generated one — is a boot failure, not a fallback. |
| 2 | ✅ Resource.DisplayName, Resource.Description, ApiScope.Required, ApiScope.Emphasize, IdentityResource.Required, IdentityResource.Emphasize |
Each says so itself: "used on the consent screen", "the consent screen may not toggle this scope off", "the consent screen highlights this scope" | Fixed in 0.16.0 (#61). The screen read none of the six: Consent.cshtml rendered ValidatedResources.GrantedScopes — raw scope strings — under one blanket Allow/Deny, so a host that named its scopes for humans saw api.internal on the page it named, Emphasize highlighted nothing, and Required forbade a toggle that did not exist. Now the page resolves each granted scope back to the IdentityResource/ApiScope behind it, which ValidatedResources already held, and renders a row per scope: the configured name, falling back to the scope value; the description under it; emphasis as a visible distinction; and a checkbox, disabled where the scope is Required. offline_access is the one row with no Resource behind it and so the one a host cannot rename. Declining an optional scope keeps it out of the grant and out of the issued token. The post is not the grant: it is intersected with the scopes the request actually validated, so a hand-written form cannot widen it, and the Required scopes are added back afterwards, because a disabled checkbox posts nothing and an omission must not narrow it either. Allowing with nothing left ticked is answered as a denial rather than a fresh prompt — a grant of no scopes is not a grant, and ConsentResponse.Granted already said so. Half-closed until 0.23.0: the fix went to Consent.cshtml, and this server has two screens that show a user a list of scopes. The device approval page still rendered raw values under a blanket Allow, so the six members were read on one and none of them on the other. Both now render one shared partial from one shared description, and one shared bound on what a post may grant. #86 |
| 3 | ✅ TokenCreationRequest.AuthorizationCodeToHash |
c_hash on the identity token |
Deleted in 0.14.0, which is the decision #63 existed to take. It was read by the id_token builder and set by nothing in src/ or test/, so no identity token this server ever issued carried c_hash. The three options were emit, delete, or document as reserved. Delete, because the member had no producer to restore: c_hash's only producer in Duende is the hybrid flow, and the hybrid response types went with the implicit non-goal. OIDC Core §3.3.2.11 requires c_hash only of an identity token issued from the authorization endpoint alongside a code; from the token endpoint — the only place this server issues one — it is permitted, not required, and Duende does not emit it there either. This overrides §1's policy that deleting an advertisement is not on the table, on the platform owner's decision and recorded here rather than taken quietly: the policy exists to stop a capability being deleted instead of built, and there is no capability here to build that Duende has. JwtClaimTypes.AuthorizationCodeHash stays — vocabulary, which §1e does not count. |
| 4 | ✅ AuthorizationRequest.IdP, AuthorizationRequest.Tenant |
"The idp hint, if present"; "The tenant hint, if present" |
Fixed in 544dbce. Both were populated by nothing: they are the established acr_values conventions (idp:, tenant:), and DefaultIdentityServerInteractionService.cs:203 copied acr_values across verbatim without parsing either out, so a login page reading IdP to send the user straight to the named external provider — the reason the member exists — always saw null. Now lifted out through KnownAcrValues.HomeRealm/.Tenant: a prefix match on a whole entry, so idport:x is not an idp: hint, and first match wins on a duplicate, because failing a sign-in over a duplicated hint nothing must act on would be worse. AcrValues still carries the list exactly as sent — lifted out, not taken out. Corrected 0.164.0: Duende's AuthorizationRequest takes GetAcrValues(), measured, which leaves the idp: and tenant: entries out; so does this server's since then (the list as sent is in Parameters). Half-closed until 0.32.0, and this row said so without noticing. Read the sentence above: it names the reader — "a login page reading IdP to send the user straight to the named external provider" — and that page did not exist. Populating the member closed the never-written shape and opened the never-read one in the same property; the hint was parsed faithfully for twenty releases and acted on by nothing, so a client asking for a specific provider still got the full chooser. The login page now honours it (idp:local suppresses the federated buttons; an unknown scheme is logged and falls back). Tenant stays unread by decision: a home realm names an authentication scheme this server has registered and can resolve, a tenant names something only the host understands. #62, #106 |
| 5 | ✅ LogoutRequest.ShowSignoutPrompt |
Whether to ask the user to confirm the sign-out | Deleted in 0.14.0 (#63). It was computed, asserted by eight tests, and read by nothing; Logout.cshtml always prompted. Deleting rather than honouring it, because honouring it means letting a GET end a session — the page carries a comment recording that refusal as deliberate, and it is the whole CSRF protection. What settled it is that the member was not merely unread but redundant: it was set to client is null in the same object initializer that sets ClientId to client?.ClientId, so it was always exactly ClientId is null and carried no fact the type did not already carry. A UI that wants to decide from attribution reads ClientId. The eight assertions were re-expressed against ClientId, which is what they were really about. |
| 6 | ✅ SamlSignInState.PeerEntityId |
The SP that made the request, recorded against its id | Fixed in 0.22.0 (#84). It was written and read on no return leg. Closing it meant giving the store something worth carrying: it now holds the validated request across the sign-in detour, and the entity id is compared when that state is resumed. The same type had two more members doing nothing, neither of them on this list — Created was written and never read, so nothing ever expired, and ReturnUrl was neither written nor read, which is a member carrying nothing at all. Created now bounds the in-memory store's sweep; ReturnUrl was deleted, since the SP-role handler it was named for does not use this store |
| 7 | ✅ ValidatedAuthorizeRequest.ResponseMode |
The response mode of the request | Deleted in 0.26.0, and the release that deleted it is the one that could have read it. It defaulted to query, was assigned by nothing and read by nothing; RFC 9207 brought the four authorization responses into one builder, which is the single place a response mode would be consulted — and there is one mode to choose between, because fragment and form_post belong to the implicit and hybrid flows this server does not have. Keeping the member so the shape matched Duende's would be a name promising a choice that does not exist, which is what §1 exists to remove. Discovery still advertises query, from the constant, which is where the fact actually lives. #93 Back in 0.130.0, set by the validator and read by the response builder: the premise that the code flow had one mode was wrong (the implicit row). |
| 8 | ✅ IServerSideSessionStore.GetAndRemoveExpiredSessionsAsync |
A sweep that removes expired sessions and returns them, "because an expired session still has clients that were signed in to it, and they are owed a back-channel logout" | Fixed in 0.13.0. Shipped in 0.12.0 read at zero sites while three comments described the sweep as something that runs — the interface's own remarks, ServerSideTicketStore.RetrieveAsync ("the sweep runs on an interval"), and AddServerSideSessions(), which cited sweeping as a cost justifying the opt-in. Nothing removed an expired row, ever: the table grew without limit and every relying party kept believing a session that ended hours ago was live. Now ServerSideSessionCleanup drains it in bounded batches from a hosted service registered by AddServerSideSessions(), handing each removed session to ISessionCoordinationService. Fixing it required fixing a second defect underneath — see the note below. #68 |
| 9 | ✅ AddExternalOpenIdConnect, AddExternalOAuth, AddExternalSaml2 (Fdyay.IdentityServer.Federation) |
"External identity provider federation for Fdyay.IdentityServer (OIDC, OAuth2 social, and SAML 2.0 SP role)" — the assembly's own package description | Fixed in 0.44.0. The shape at assembly scale rather than at member scale: three public extension methods, a whole shipped NuGet package, called from nowhere in src/ or test/. The only mention anywhere was a commented-out line in the host's Program.cs. Everything the assembly does — SignInScheme onto the external cookie, SignOutScheme onto the local one, the SAML SP handler wired as a broker — was configuration that read as a capability and had never once been executed. It is why the outbound federated sign-out of 0.42.0 could ship not working. Every test that federated did so through a hand-rolled AuthenticationHandler written for the test, which is a fixture agreeing with a fixture; driven for the first time through the real handlers, the release's own feature sent a LogoutRequest whose NameID was the empty string, and a second registration the options deliberately allow answered an ordinary sign-out with a 500 that left the user signed in. The fixture is the fix: a host registering all three provider kinds through the shipped methods, with the SAML one driven end to end (this server owns both halves of that protocol, so SamlResponseGenerator plays the upstream IdP with no network) and the OpenID Connect and OAuth ones asserted on the configuration the methods apply, because driving those needs an upstream this repository does not contain. That asymmetry is stated in the fixture rather than glossed. #40 item 15 |
| 10 | ✅ DPoPProofValidationContext.AccessToken / the cnf claim |
"The access token presented alongside the proof, when there is one" — set only where a token is used rather than obtained, and the input to the ath check |
Fixed in 0.45.0. Both shapes at once, in the same mechanism. cnf is written two ways — a DPoP key thumbprint since 0.17.0, an mTLS certificate hash since the RFC 8705 release — and relayed faithfully to third-party resource servers through introspection, where a comment explains that a caller reads cnf.x5t#S256 off it. It was read back by nothing: DefaultTokenValidator does not look at it, and neither did /connect/userinfo or the token-exchange subject-token path, the two places this server accepts an access token it did not just mint. A stolen sender-constrained token was an ordinary bearer token wherever this server is itself the resource — and at token exchange it was worse than that, because the exchange hands back an unbound token for another audience: the constraint gone rather than merely unchecked. Underneath it, the guard: DPoPProofValidationContext.AccessToken exists for no other purpose than this call, and was set at zero sites in src/ — its only non-null assignment anywhere was the unit test's own context builder, which is PersistedGrant.ConsumedTime exactly: a check proven to work by the only code that ever feeds it. Two sentences kept it invisible, and both were true. The README's DPoP section ended "validating proofs at a resource server is that server's job, not this one's" — true of your resource server, and this one hosts its own. And this row's own §5 entry closed with "resource-server-side validation belongs with item 10", filing a defect in shipped code as a feature not yet built. Now one rule with one owner, ISenderConstraintValidator, at every place a presented token is accepted. #40 |
| 11 | ✅ DefaultCorsPolicyService.AllowedOrigins, DefaultCorsPolicyService.AllowAll |
"The origins allowed when AllowAll is false", on a class offered to "hosts that keep their browser clients' origins in configuration rather than in the client registrations" |
Fixed in 0.49.0. Written at zero sites in src/ and test/, read on every cross-origin request. There was nowhere for a host to write them: both are properties on the instance, AddCorsPolicyService<T>() registers a type, and the registration is transient — so a host that took the class at its word got a fresh, empty instance per request and a policy service that refused every origin it had configured. Nothing in the class is wrong; a unit test on it passes and always did, which is why this survived while the CORS surface itself was tested end to end. What was missing was a way in. AddDefaultCorsPolicyService(configure) builds and configures a singleton, and the tests are container tests deliberately — the gap is only visible from where a host stands. This row was found by building something else: the caching decorator's tests needed an inner policy service with fixed origins, and there was no way to make one. §6 |
| 12 | ✅ DynamicProviderOptions.Types, AddDynamicProviders/AddDynamicOidcProviders, and four options never written at a non-default value |
Each says what it is for: a collection "the options binding has to be wired up for", two registrations that make stored identity providers work, and four settings a host is invited to change | Closed in 0.53.0, and this row is the audit rather than a discovery. Nothing in 0.45.0–0.52.0 had been checked backwards against the two questions the preceding eleven rows leave behind, so the check was run: (a) every new public member against a consumer in src/ that is not a test, and (b) every new or touched option against a test at a value other than its default. Types failed (a) outright — zero readers, and a doc comment naming a consumer that does not exist — and was deleted, the outcome rows 3, 5 and 7 established for a member with no answer to give. The two dynamic-provider registrations failed (a) at row 9's level: called only from a test fixture, which is row 9's rule verbatim one release after it was written down, so the demo host now registers the mechanism with an empty store — the registration boots under scope validation, which is the one thing a fixture cannot prove about a singleton scheme provider reading its store through IServiceScopeFactory. Four options failed (b) — LocalApiAuthenticationOptions.SaveToken, IdentityServerOptions.EmitScopesAsSpaceDelimitedStringInJwt, SupportedRequestObjectSigningAlgorithms and DynamicProviderOptions.PathPrefix — each tested only at the default it ships with, which is the same evidence a constant would produce; all four now have a test at the other value. The fifth finding was narrower than first reported and is recorded because the error is the useful part: DynamicOidcProviderConfigurator was called dead on a grep for its type name in test/, and it is not — it is driven through DI by behaviour, which is the right way. What was actually unasserted was four of its six assignments, three of them stored at exactly the values the handler's own defaults produce, so a row that never reached it looked identical |
Rows 1, 2 and 4 were wiring in the §1c sense — the value exists and something needs to read it, or
the producer is one parse away — and all three are done. Rows 3 and 5 were not: each had a defensible
answer either way, and picking one silently is how a token shape or a CSRF posture changes without
anyone deciding it. Both were decided in 0.14.0, and both were decided by deleting the member —
recorded here with the argument, which is what the rows were held open for. Neither deletion gives
up a capability Duende has: c_hash's only producer is a flow this server does not implement, and
ShowSignoutPrompt was a restatement of ClientId is null. Row 1 was the largest — an off switch is worth
nothing without the static-credential path it is meant to switch to — and it closed in 0.15.0 with
that path built rather than with the switch merely read. Row 2 was the same shape one step further
out: six members describing a control the page did not have, so 0.16.0 closed it by building the
control rather than by finding somewhere to print the values.
Row 12 is what the preceding eleven rows are worth if nobody runs them against new code. Every row from 9 on was found by building a consumer, and each was written up as a rule — row 9's a capability whose only exercise is a fixture written to exercise it has not been exercised, row 11's can the host reach the object it is on, and 0.50.0's a setting that does not exist still has a value — the library's. Eight releases then shipped without either question being asked of them, and asking both backwards over 0.45.0–0.52.0 took under an hour and found four things, one of which reproduced row 9 exactly. The cost of a rule written down and not run is the defect it was written for, one release later. The two questions are cheap enough to be a release gate rather than an audit, and from here they are: a new
publicmember names its consumer insrc/, and a new or touched option has a test at a value it does not default to.
The option half needs stating separately, because it is not row 12's shape and not §1e's. A setting tested only at its default is not unread — the read site is right there and the test runs through it. It is undistinguished: the outcome is identical to the one a hardcoded constant produces, so the test passes with the option deleted and the constant inlined. Three of the four found here would have. That is a fourth defect shape beside §1e's three, and the only query that finds it is over the tests rather than over
src/.
0.54.0 ran row 12's gate over the rest of the tree, and the gate turned out to be the wrong shape. Item 17 records it as a new or touched option has a test at a value it does not default to, which only ratchets forward. Asked of the options that already existed it returns 49 — every one of which the gate as written would have waved through forever. The first of them taken,
PushedAuthorization.Required, had a defect under it in both halves of its rule. A gate that only applies to new code is a gate that grades the author rather than the tree, and the version that finds anything is asked of the whole surface at least once. The remaining candidates are not all defects and most are not: about half were checked by hand in 0.54.0's sweep and the endpoint toggles, theShow*flags, the CORS trio, the Dapper cleanup switches andAlwaysEmitConfirmationClaimwere each read for a live fault and none found. They are coverage, and the distinction is worth keeping — a list of 49 reported as 49 defects is the kind of count note 15 exists for.
One thing 0.54.0 deliberately did not change, and the reason is the environment rather than the code. A client required to push that sends no
request_uriis refused withinvalid_request_uri— the same code as a reference that was malformed or stale — which describes a parameter the request did not carry. Whether RFC 9126 §4 requiresinvalid_requestthere could not be checked:rfc-editor.organddatatracker.ietf.orgare both blocked by the build environment's egress proxy. Re-measured in 0.65.0 and still blocked, which is worth recording as a checked fact rather than a remembered one — and worth separating from the "33 event types" figure this paragraph used to file alongside it, which turned out not to be behind this wall at all. A wire-visible error code is not a thing to change from memory of a specification, which is the failure mode this programme has paid for more than once, so it is recorded here and left alone until the text can be read.Answered in 0.96.0, and not by the text.
rfc-editor.organddatatracker.ietf.orgwere re-measured and are still blocked. The blocker was real; the question was the wrong one. Since the 1.0 rule, the measure of a wire-visible code is Duende 8.0.1, and its package was in hand the whole time:RequestObjectValidator.LoadRequestObjectAsyncrefuses a request outside the PAR endpoint, whenPushedAuthorization.Requiredor the client'sRequirePushedAuthorizationis set and therequest_uriis missing or lacks the PAR URN, withinvalid_request, "Pushed authorization is required." — decoded from the IL, condition and all. It is that here now. Decoding it also found an ordering difference: Duende'sAuthorizeRequestValidatorloads the client before the request object, so a request naming no client, or an unknown or disabled one, is refused for that first; here the server-wide switch refused all three asinvalid_request_uribefore the client was looked at. The rule is now asked only of an enabled client.
0.56.0 finished the sweep 0.54.0 started, and the gate it was run under had a hole in it. All 46 remaining candidates were read. Four were defects, all one shape:
UserInteractionOptions.LoginReturnUrlParameter,ConsentReturnUrlParameter,ReturnUrlParameterandLogoutIdParametername the query parameters this server puts on its interaction redirects,InteractionUrl.Resolveemits every one of them faithfully, and the login, consent and logout pages bound a hardcoded name — so at any non-default value the user signed in successfully and was sent nowhere. Note 23's shape, and 0.19.0's defect reachable a second time, through configuration rather than through a missing parser. The other 42 are coverage gaps; each was read for a live fault and none had one.What makes those four a defect rather than a division of labour is that the correct pattern was already in the same assembly:
Device.cshtml.csandError.cshtml.csreadRequest.Query[configured]. Note 11 at page scale — the rule applied to two of its five halves. The release also found one destination with two names: the SAML single sign-on endpoint emittedReturnUrlParameterwhile the authorize endpoint emittedLoginReturnUrlParameter, so renaming either broke one protocol and left the other working.
The gate hole is the more transferable finding, because it was in this document's own rule. Item 17 of #40 says an option has a test at a value it does not default to.
LoginReturnUrlParameterandConsentReturnUrlParametersatisfied it — with this:options.LoginReturnUrlParameter = "loginReturn"; options.LoginReturnUrlParameter.ShouldBe("loginReturn");A property round trip. It asserts that a setter stores what it was given, which is true of every property ever written, and it kept two broken options out of the sweep entirely for two releases — they were only found by following the third, which happened to have no test at all. A gate answered by a round trip is a gate with a hole in it, and the fix is in the gate rather than the tree: the test has to assert an observable outcome, not the property's own value. The two round trips are deleted, so the sweep is satisfied now only by the behaviour tests.
Three of this release's own tests were wrong, and two of them passed against the defect. Predicted five red, got three.
A_renamed_login_return_parameter…signed in and then made a fresh authorize request, which finds the new session and issues a code whether or not the return URL survived — the ninth method note, for the second release running, in a test written while citing it. The SAML one renamed the parameter the SSO endpoint did not use, so it was green against the very mismatch it was written to expose. And the logout one asserted a client name the page cannot know without anid_token_hint, so it would have stayed red against a correct server. Note 26 earning its place twice over: predicting the red set is what turns each of these from a silent pass into a finding.
0.57.0's unpredicted pass was the control, and a control is the one test that can be wrong in a way nothing else catches. Predicted five red, got four. The one that passed asserted that a sweep which found nothing recorded nothing — true of the server as it was, which recorded nothing whatever it found. An assertion that counts to zero is satisfied by silence, so the test written specifically to catch a sweep raising one event per pass could not have been red before either event existed, and could not have caught that anyway: it seeded one expired session, and "one per pass" and "one per swept session" are the same number when there is one. It now seeds two expired rows and a live one and names the two it expects, which fails on a per-pass event (no session id to name), on a sweep reporting what it looked at rather than what it removed (the live row), and on the tree as it was (nothing at all). The idle assertion stays, second, after the store has been drained — worth nothing alone, worth something once something has been recorded. Two releases running, the finding was in the gate rather than the tree: 0.56.0's round-trip hole and this one are the same failure at different scales, a check whose passing is compatible with the defect it names.
Row 11 is not a member the sweep missed. It is a question the sweep does not ask.
AllowAllis one of the 453 settablepublicmembers, and its write-site count is genuinely zero — the sweep would have flagged it had it been run again. But the member beside it,AllowedOrigins, is get-only: you mutate the collection, you do not set the property, so by the sweep's own definition of its subject it was never a candidate. And neither count is the interesting one here. Both members are perfectly writable if you are holding the instance; what a host could not do was get hold of it, because the only registration that existed builds one per request from the type. The question that finds this is not "does anything write to this member" but "can the host reach the object it is on" — the same level row 9 was at, one above a member, which is the level the query cannot see. The other nine get-only collections declared undersrc/were checked against that question when this row was written: each is either on an options object a host configures throughAddIdentityServeror on a result built by the code that returns it, so this is the one.
Row 10 is the one a true sentence hid, and the sweep had every chance at it. Unlike row 9 it is an ordinary member read at zero sites, which is precisely what §1e queries for. What kept it off the list is that both places it should have been noticed said something correct. The README's DPoP section closed with "validating proofs at a resource server is that server's job, not this one's" — true of the reader's resource server, and silent on the fact that this server hosts its own. This document's §5 row closed with "resource-server-side validation belongs with item 10", which filed a defect in shipped code under a feature not yet built, so a reader auditing §1e saw the subject already accounted for. A sweep is answered by the tree, not by the prose about the tree — and a sentence that is true of the general case is the most effective way to stop anyone checking the particular one.
Row 9 is the shape one level above a member, and it is the level the sweep cannot see. The query behind §1e asks, of every settable
publicmember, whether anything reads it. It cannot ask whether anything calls an assembly, andFdyay.IdentityServer.Federation— three public extension methods, shipped as its own package — was called from nowhere for the whole programme. What that costs is not the assembly going stale; it is that anything built against it is verified only by whatever stands in for it. The outbound federated sign-out of 0.42.0 was tested against handlers written for the tests, passed, shipped, and did not work. The rule the row leaves behind: a capability whose only exercise is a fixture written to exercise it has not been exercised.
Row 8 sat on top of a third-shape defect, and the obvious fix would have reproduced it.
DefaultBackChannelLogoutService.SendLogoutNotificationsAsyncopened withif (httpContext is null) return;, becauseIIssuerNameServicecould only derive an issuer from a request. Every caller was inside one, so the branch was unreachable and the silence cost nothing. A sweep in a hosted service is the first caller that is not: it would have removed the rows, revoked the grants and notified nobody, silently, looking exactly like a sweep that worked — and it would have passed any test that mockedIBackChannelLogoutService. This isEnableJwtRequestUri's shape andSessionStateService.OriginOf's: a guard whose call site cannot run, producing from outside the outcome it exists to prevent. Fixed with a context-freeGetIssuer()and a warning in place of the silent return, and pinned by a test that drives the real service with noHttpContext. A new caller can make an unreachable branch reachable, and nothing about the branch changes when it does.
Row 1 sat on the same trap as row 8, one layer along, and it is now twice.
CreateJwksAsyncopened its loop withif (info.Key is not JsonWebKey jwk) continue;. That was safe for exactly as long as the only producer of validation keys wasRsaKeyContainer.ToPublicJsonWebKey— the branch could not run.IValidationKeysStoremade it reachable with keys a host supplies, and what it produced there is the worst failure this endpoint has: the key signs tokens perfectly, never appears in the JWKS, every relying party rejects every token, and nothing anywhere says why. Closed by converting rather than skipping, throughPublicJsonWebKey.From, which also strips the private half —JsonWebKeyConvertercarries it across, and a JWKS is served to anyone who asks. Twice now the fix for a §1e row has had to fix an unreachable branch underneath it first (0.13.0's back-channel logout issuer was the other), which is enough to make it a rule rather than a coincidence: when a change adds the first caller that reaches existing code from a new direction, read that code for what it does from the new direction, not for what it was written to do. Nothing about the branch changes when the caller arrives; only whether it runs.
The method note, and it is the fourth shape mirrored. Row 2 was invisible to every earlier sweep, and not because nobody pointed one at those files. The sweep matched member names without receivers, so
Resource.DisplayNamecounted the login page's read ofAuthenticationScheme.DisplayName, andResource.DescriptioncountedPersistedGrant.Descriptionand a line of SQL. Both looked read. The epic records a search narrow enough to be convenient being narrow enough to be wrong — the@Versionfilter that excluded its own disproof. This is the same failure from the other side: a search broad enough to be convenient is broad enough to be wrong, and the spurious matches read as coverage. Either way the filter decided the answer and the code never got a vote, and either way it fails looking like a pass.
A second method note, from 0.19.0, and it is about where an assertion stops. SAML sign-on could not complete for a user without a session, and a test named
Unauthenticated_sso_redirects_to_loginpassed throughout. It asserted the redirect out to the login page — which worked — and said nothing about the hop after it, which did not. The endpoint was covered, the login page was covered, and the seam between them was covered by neither: each side asserted its own half of a handover and nothing asserted that the halves met. This is the read-but-unreachable shape arriving through a test rather than a guard, and the check that finds it is the same one — follow the path a user actually takes, one hop past where the test stops. The three tests added with the fix are written that way: the browser goes back to/saml/sso, and the assertion the service provider asked for arrives, because a redirect that then failed would have satisfied the first and left the service provider exactly as unanswered.
promptwas honoured forloginand for nothing else — ✅ fixed in 0.21.0. Found while writing 0.20.0's tests: the withdrawal case, a user unticking "remember" on a second visit, could not be driven through a browser at all, because once a decision is remembered nothing took the user back to the consent page.AuthorizeEndpointacted onloginalone, soconsentandnonewere accepted and dropped — andnoneis the one that costs a client something. It is what a silent-renew iframe sends, and answering it with a redirect to the login page is a login form in a frame the user never opened. This server had honoured the SAML equivalent since 0.16.0: §11 recordsIsPassivebeing fixed with that exact reasoning, and then recordsForceAuthnas "one server enforcing a demand over one protocol and dropping it over the other". That sentence was true in both directions and only one of them was written down.nonenow answerslogin_required/consent_requiredat each of the three points that would show a page;consentforces the screen whatever the store says;nonewith any other value is refused per Core §3.1.2.1.select_accountis still not acted on and gets no constant, because there is no account picker to send anyone to. As of 0.142.0 it is acted on as Duende acts on it — the login page, where a user picks the account they sign in with — and every value outsideUserInteraction.PromptValuesSupportedis refused ("Unsupported prompt mode"), as Duende's validator refuses it.
A third method note, from 0.20.0, and it is a direction the completed sweep does not look in.
ConsentResponse.Descriptionis documented as being "for the user's grant list".PersistedGrant.Descriptionis in theCREATE TABLE, theSELECTand the upsert of both dialects.Grant.Descriptionis read back byGetAllUserGrantsAsyncand handed to whatever account screen a host builds. Every one of those is a real site, so the member is written and it is read — and the sweep, which counts write sites and read sites, finds nothing wrong. Nothing insrc/connected the two: the write path built its grant without a description andConsenthad no member to carry one, so a host that named a decision watched the name disappear. A member can be written, and read, and still do nothing, when the write and the read are not the same value's. The two sweeps ask "does anything set this" and "does anything read this" separately; neither asks whether a particular write reaches a particular read, and no count can — it takes following the value. What makes this findable is the same habit as the rest: take a member's own documentation as a claim, and check it end to end.ConsentResponse.Descriptionsays "for the user's grant list", and that was the whole test.
A fourth method note, from 0.22.0, and it is about the size of the thing a row names. Row 6 named one member,
SamlSignInState.PeerEntityId, written and never read. The obvious close is to read it: compare the entity id on the return leg and mark the row ✅. That would have been true and worthless, because the store was the dead thing. Its state was written when the browser was sent to the login page and taken when the browser came back, and in between the endpoint put the whole AuthnRequest in the return URL and re-validated it from what came back — so nothing the store held was load-bearing, and the type had two further members proving it:Createdwritten and never read, andReturnUrlneither written nor read. When every member of a type is inert, the finding is the type, not the member — a row that names one member is naming the symptom the sweep can see, and the check is to ask what the type is for and whether anything depends on the answer. Here nothing did, and the endpoint's own workaround — carrying the request through the browser — was the defect the store existed to prevent, costing a POST-bound request the signature it was sent with.
A fifth method note, from 0.23.0, and it is the same shape for the third time. Row 2 was found on the consent screen, fixed on the consent screen, and marked ✅. The row is not about a screen: it is about six members that describe a scope to a human, and this server shows a human a list of scopes on two screens. The device approval page went on rendering raw values under a blanket Allow for seven releases, with the row closed above it. 0.21.0's note says the same thing about
promptandForceAuthn— "one server enforcing a demand over one protocol and dropping it over the other" was true in both directions and only one was written down — and 0.19.0's says it about a handover asserted from each side and from neither in the middle. Three times now, so it is a rule: when a fix is for a member rather than for a page, find every reader before closing the row, because the sweep that found the member counted read sites and the fix only had to satisfy one of them. The check is cheap and it is the same one every time: ask what else is entitled to read this, and go and look.
A sixth method note, from 0.24.0, and this one is about a test that passed. The silent revocation was not unread, unwritten, or unreachable. It was asserted, by a test called
Revoking_does_nothing_when_nobody_is_signed_in, which was true and had been green since the member shipped. The name is the whole finding: it states a fact about the code and never asks whether the fact is desirable. Nothing is a defensible answer to a question — "what has the signed-in user granted" is empty when nobody is signed in — and no answer at all to a command, because a caller told its revocation succeeded while nothing was revoked has been lied to. §1e's fourth shape is an assertion narrow enough to be convenient; this is the same failure with the narrowing done by the name: a test named for what the code does can only ever pass. The check that finds it is to read a test's name as a claim someone might dispute, and ask who would.
A seventh method note, from 0.28.0, and it is the sixth one paying off. The check the sixth note prescribes — read a test's name as a claim someone might dispute — was run over the token request validator and found
Unknown_but_allowlisted_grant_type_is_unsupported_grant_type, green since it was written. The name concedes its own scope in one word: allowlisted. Why would a test about an unknown grant need the client to be allowed it? Because that was the only way to reach the branch: the permission check ran before the support check, so the case every deployment actually has — a client not registered for a grant that does not exist — returnedunauthorized_client, which says the grant exists. The setup had to construct a configuration nobody runs in order to see the right answer. So the sixth note generalises: an assertion can be narrowed by its name, and a test can also be narrowed by what its arrangement had to invent. A[Fact]whose setup contains a line that makes no sense for a real caller is testing a path no real caller takes; ask what the arrangement is for, and if the answer is "to reach the code", the case that matters is the one it stepped around.
An eighth method note, from 0.30.0, and it is about what this sweep cannot see. Every note above works by reading something that exists — a row, a test name, a call site, an arrangement. §1e's own five shapes are all shapes of written code: read and never written, written and never read, read but unreachable, searched too narrowly, added and never consulted. A control that was never started anywhere leaves nothing to read. There was no
Content-Security-Policyin this tree, so there was no options group whose absence looked odd, no seam registered and unused, no test named after a thing it half-checked; agrepfor it returns nothing and nothing is what a clean sweep also returns. What found it was a different question, asked of the other end: not what does the code do, but what does a browser actually receive. The two are not the same audit, and the second one is the only one that sees a missing header — or a missing cookie attribute, or a missing cache-control on a page carrying a token. The parity matrix helped only by naming the row; the row said "options for interactive pages", and the defect was that the pages had no protection to give options for. Read a row as a claim about behaviour, not about API surface, and when a row names the knobs on a control, check the control exists before checking the knobs.
A ninth method note, from 0.31.0, and it is about the fixture rather than the code or the test. The introspection endpoint let any authenticated API read any token, and a test named
Introspection_reports_a_valid_token_as_activehad been green over it since it was written. Nothing was wrong with that test. The demo host registered exactly one API resource — and with one API, every token is that API's, so the correct request and the defective one are the same request. The test could not have failed, and no test written against that fixture could have. This is not the seventh note's shape: nothing here had to invent a configuration to reach the code, and no assertion was narrowed. The arrangement was simply too small to contain the distinction the rule is about. So: where a rule is about telling two things apart, a fixture holding one of them proves nothing, and proves it in green. One API, one client, one scope, one signing key, one tenant — each is a place where an entire class of rule is untestable and looks tested. The check is cheap to run on a fixture: for every rule this server enforces, ask what the fixture would need at least two of, and count what it has.
A tenth method note, from 0.32.0, and it is the ninth one paying off — twice. Running that count across the existing fixtures found one external identity provider, and behind it §1e row 4: the
idp:hint parsed since 0.12.0 and read by nothing. Two things are worth keeping from it. First, "fixed" is a claim about a direction. Row 4 was a never written row, closed by writing the value — which closed one shape and opened the other in the same property. §1e catalogues "never read" and "never written" as separate shapes, and they are the same defect from opposite ends: a value nobody produces and a value nobody consumes are both a member that does nothing. Closing either one is a natural moment to create the other, so when a never-written row is closed by writing the value, ask what reads it before marking it done. Second, the row said so already. Its own text named the consumer — "a login page readingIdPto send the user straight to the named external provider" — and no such page existed. The matrix described the reader that was missing, in the sentence explaining why the fix mattered, and stayed marked closed for twenty releases. A row that states its own consumer is making a checkable claim; check it.
All five subsections resolve the same way — implement — but they differ in how much is owed. §1c is
mostly wiring: the value is already parsed and stored, and something needs to read it. §1b is a
contained defect in one registration path. §1a is real feature work, several releases of it. §1d is
three of §1c's shape and one of §1a's. §1e is four of §1c's shape (three of them closed), one of
§1a's, two that needed a decision before any code and got one in 0.14.0 — both deleted — and one
— row 8 — that was produced by this programme rather than found in it, and closed in the release
after. All eight are closed as of 0.26.0, the last of them by deletion. Row 9 was opened and
closed in 0.44.0, and it is the one the sweep could not have found: its unit is an assembly, not a
member, and no query over settable public members reaches a package nothing references. Row 10
was opened and closed in 0.45.0, and the sweep could have found it — it did not, because the two
sentences that hid it were both true. Row 11 was opened and closed in 0.49.0, at the level above
a member: both its members were writable by anyone holding the instance, and no host could get hold
of one. Row 12 was opened and closed in 0.53.0, and it is the only row that came from running
this table's own rules backwards rather than from building anything — which is why it found row 9's
shape again, eight releases after row 9 was written down.
Not counted as findings: unused vocabulary constants that promise no behaviour — a host is free to use
OidcConstants.ServerErroras a string — andGrantTypes.CodeAndClientCredentials, a helper for a combination that is fully implemented and simply not referenced internally.
2. Protocol endpoints
| Endpoint | Duende | Here |
|---|---|---|
| Authorize | ✅ | ✅ |
| Token | ✅ | ✅ |
| UserInfo | ✅ | ✅ — a boolean, integer or JSON claim answered as that JSON type since 0.111.0, as in the token; it was a string. 0.119.0 — the request validator, and the openid requirement this row did not know about. Measured from Duende 8.0.1's UserInfoRequestValidator: the token is validated against the openid scope, so a token without it is refused insufficient_scope — here no userinfo request had ever been asked for openid, and an API-only token was answered with the user's claims; it is now 403. Then a sub (invalid_token without one); then the user: the principal of the server-side session the token's sub and sid name, through IServerSideTicketStore.GetSessionsAsync, when server-side sessions are on and the session is there, else the token's claims; then IsActive with the caller UserInfoRequestValidation. IUserInfoRequestValidator, UserInfoRequestValidationResult (plus this server's Client: Duende's token result carries the client, this one's carries claims) and an open UserInfoRequestValidator — in the ASP.NET Core assembly, since it reads the issuer off the request and the user off the ticket store. IUserInfoResponseGenerator.ProcessAsync takes the validation result, Duende's shape. ProtectedResourceErrorResult answers with Duende's status table — invalid_request 400, insufficient_scope 403, the rest 401 — where it was always 401, and a request presenting no token is invalid_token, as Duende answers it, where it was invalid_request. Kept: the refusal of a token whose client is gone or disabled (invalid_token), which Duende's validator does not make, and the sender-constraint check, asked here after the validator. Recorded for the whole-surface re-measure: when Duende builds the user from the token it drops the protocol claims (Constants.Filters.ProtocolClaimsFilter, measured in the Where lambda), where this server hands the profile service all of them, as before. Taken 0.127.0: the user built from the token leaves out Duende's fourteen ProtocolClaimsFilter types. 0.157.0 — Duende's json claim value type (re-measure decision 3), measured from ClaimsExtensions.GetValue and TokenExtensions.AddObject: a claim of value type json, compared ignoring case (IdentityServerConstants.ClaimValueTypes.Json), is answered as the JSON it holds here, in a token and at introspection — until then only IdentityModel's JSON was, so a Duende profile service's address was a string holding JSON. Breaking for a claim marked json that was meant to stay a string. Ours: JSON_ARRAY read too; text that does not parse stays text in a token, where Duende's AddObject throws. 0.157.0 — Duende's double claims (re-measure row 36, decided 2026-10-14), measured from the same two: a claim of value type XMLSchema#double that parses is a JSON number in a token, userinfo and introspection — a string until then. Breaking. Ours: read with the invariant culture everywhere (Duende's GetValue reads by the current culture); text that does not parse, NaN and infinities stay text, where Duende's AddObject throws. 0.173.0 — auth_time a number, amr an array (OpenID Connect Core 1.0 §2; decided 2026-11-03, breaking before 1.0): when the profile service releases either, auth_time is answered as a JSON number whenever it is an integer, whatever its value type (a session's, from IdentityServerUser, is typed string, as Duende's), and amr as an array of its distinct values. Ours: Duende's ToClaimsDictionary answers both as their claims say — a string-typed auth_time a string, one amr a string. 0.174.0 — an expired token says so, measured from Duende 8.0.1's UserInfoRequestValidator (the token validator's error passed on) and ProtectedResourceErrorHttpWriter (expired_token written as error="invalid_token", error_description="The access token expired"): an expired JWT is now answered with that description; every other refused token is invalid_token without one, as before. Ours: a custom token validator's own code is not passed to the wire (Duende's is); an expired reference token reads as unknown (the store returns none), so it carries no description. |
| Discovery + JWKS | ✅ | ✅ — 0.139.0: Duende's discovery options, measured from its DiscoveryOptions, DiscoveryResponseGenerator, BaseDiscoveryEndpoint, DiscoveryKeyEndpoint and the two result writers (re-measure release 15). ShowExtensionGrantTypes; ShowTokenEndpointAuthenticationMethods, and the revocation and introspection *_auth_methods_supported this document did not have, each under its own switch, one list from the secret parsers, with the client-assertion algorithms beside each only when private_key_jwt is on it (the token endpoint's were always written); CustomEntries after the document's own, ~/ expanded under ExpandRelativePathsInCustomEntries, one already present skipped with Duende's error; ResponseCacheInterval on the document, the key set and the OAuth metadata — none by default, as Duende's, where every one was sent no-store; EnableDiscoveryDocumentCache and DiscoveryDocumentCacheDuration over the IDistributedCache. Found: the key set was served with ShowKeySet off; a 404 now. Ours: one result for both documents, and the cache keyed by the issuer alone. DynamicClientRegistration not taken (decision 6). |
OAuth 2.0 authorization server metadata (RFC 8414, /.well-known/oauth-authorization-server) |
✅ EnableOAuth2MetadataEndpoint, IIssuerPathValidator |
✅ as of 0.125.0 — found untracked 0.124.0, measuring Duende's discovery surface for the release plan. Measured from Duende 8.0.1's OAuthMetadataEndpoint.ProcessAsync, OAuthMetadataHelpers.IsMatch and DefaultIssuerPathValidator (IL decoded). The discovery document a second time, at /.well-known/oauth-authorization-server: GET only (405); on by default under its own EnableOAuth2MetadataEndpoint, independent of EnableDiscoveryEndpoint, as in Duende. Matched with whatever follows the well-known path — StartsWithSegments, ignoring case, through Duende's Endpoint.IsMatch (taken: a host's own endpoint can match the same way) — because RFC 8414 §3.1 puts an issuer's path after the segment. That path must satisfy IIssuerPathValidator (default: no path, or the issuer's AbsolutePath ignoring case; an issuer that is not an absolute URI accepts no path), and the issuer must then be exactly the request's scheme, host and that path, ordinally — so a configured IssuerUri on another host publishes no OAuth metadata here, as in Duende; else 404. A request under a path base is a 404. The document is built for that issuer, so its endpoint URLs carry the path, as Duende's do through ServerUrls.BasePath. Kept, recorded: a disabled endpoint falls through the middleware, as every endpoint here does, where Duende's answers 404 itself — the same 404 in a host with nothing else at the path, and the path left to a host that serves its own. EndpointNames.OAuthMetadata ("OAuthMetadata") and ProtocolRoutePaths.OAuthMetadata, Duende's. The CORS paths are unchanged: Duende's defaults do not include it either (measured). |
| End session | ✅ | ✅ — 0.123.0: the request validator, and a hint that ended any session. Measured from Duende 8.0.1's EndSessionRequestValidator.ValidateAsync and ValidateIdTokenHintAsync (IL decoded; EndSessionHintValidationOutcome Valid 0, Invalid 1, RequiresConfirmation 2). The id_token_hint is validated by ITokenValidator — signature, issuer, audience, type, and the host's ICustomTokenValidator — where the endpoint checked its signature alone with issuer and audience off, and read its client from the first audience. For a signed-in user the hint must name their session: its sid the session's when both are there, else its sub the user — Duende's default ValidateIdTokenHintAsync, protected virtual here as there, which a host overrides to ask for confirmation (RequiresConfirmation) or refuse more. Until 0.123.0 any valid hint ended whatever session the browser held, another browser's or another user's; now it goes to the prompt. The post_logout_redirect_uri is kept when the redirect URI validator accepts it for the client (and it names a location), state only beside it; RequireAuthenticatedUserForSignOutMessage refuses an anonymous request in the validator. IEndSessionRequestValidator, ValidatedEndSessionRequest, EndSessionValidationResult, EndSessionHintValidationContext, EndSessionHintValidationResult, EndSessionHintValidationOutcome, and an open EndSessionRequestValidator. Kept, recorded: a verified hint naming this session ends it without a prompt and offers the return on the signed-out page, where Duende's endpoint always goes to the logout page and leaves the prompt to it; a return the validator will not keep sends the user to the prompt, where Duende drops the return and goes on to the logout page with the client's message, as it does for every request (EndSessionHttpWriter, measured: it stores the message and redirects; signing out is the page's). Not adopted, recorded: ValidateCallbackAsync and EndSessionCallbackValidationResult — there is no end-session callback endpoint here, the signed-out page carrying the front-channel frames — and ValidatedEndSessionRequest.SamlSessions. 0.156.0 — ValidatedEndSessionRequest.UiLocales (re-measure release 30), Duende 8.0.1's, measured: read before anything can refuse the request, one over InputLengths.UiLocale ignored rather than refused (Duende logs it; this validator has no logger); the endpoint tells a local logout page through IUiLocalesService — see the IUiLocalesService row. Re-measured 0.174.0 — EndSessionValidationResult.Error is the literal "Invalid request", as Duende 8.0.1's EndSessionRequestValidator.Invalid sets it (its ErrorDescription prefixes "End session request validation failure: ", which ours does not). Not changed: it is Duende's value, and it reaches no response — Duende's EndSessionEndpoint logs it and goes on to the logout page, ours goes on without reading it — while RP-Initiated Logout 1.0 defines no error code for the end-session endpoint. |
| Introspection | ✅ | ✅ — and the row was ✅ through a disclosure it did not describe. Until 0.31.0 the endpoint authenticated its caller as an API resource and then never asked which API: ApiSecretValidationResult.Resource was produced by the validator and read at zero sites in src/, so any API registered here with a secret could introspect any valid access token this server had issued and read the whole of it — subject id, every claim, the scopes, and the cnf the token is bound to. RFC 7662 §4 requires the opposite. An API is now answered active: true only for a token naming it among its audiences; membership rather than sole ownership, since a token issued for two APIs is legitimately readable by both. The static {issuer}/resources audience is excluded by name, because with EmitStaticAudienceClaim on it sits on every token and an API registered under it would match everything — the same defect wearing the fix's costume. The check could not be written against the aud claim: a reference token keeps its audiences in Token.Audiences and validation returned only Token.Claims, so reference tokens had no aud at all and the obvious check would have answered active: false for every one of them, indistinguishable from tokens expiring early. Both forms now report aud from one source. Duende additionally filters the reported scope to the calling API's own scopes; that is deliberately absent here — a disclosure between two legitimate audiences, much smaller than the one closed, and filtering risks withholding what an audience already relies on. #104 0.118.0 — the per-endpoint validator, and three capabilities this row did not know about. Measured from Duende 8.0.1's IntrospectionEndpoint, IntrospectionRequestValidator, IntrospectionResponseGenerator and IntrospectionHttpWriter. Client callers: the endpoint authenticates an API resource, or failing that a client (IClientSecretValidator); a client introspects its own access tokens — client_id its own, token_type: access_token added — and its own refresh tokens through IRefreshTokenService.ValidateRefreshTokenAsync(token, client), answered with client_id, token_type: refresh_token, iat, exp (creation plus lifetime), sub and a scope per authorized scope; token_type_hint (access_token, refresh_token; anything else ignored) picks which to try first, the other second. An API introspects access tokens only. Signed answers (RFC 9701): an Accept of exactly application/token-introspection+jwt is answered with a JWT of typ token-introspection+jwt — issuer, the caller as audience, iat, the answer as a JSON token_introspection claim; Duende's TokenExtensions drops exp, nbf and sub for that type (its token lifetime is 0 and IncludeJwtId false), and so does this server's token creation service now. missing_token: no token is a refused request, 400 with missing_token, where it was answered active: false. The scope filter this row declined is taken: Duende's AreExpectedScopesPresentAsync makes a token inactive to an API whose scopes it carries none of, and the response reports only the API's own scopes; the decline's reason — withholding what an audience relies on — was weighed against parity and lost. Kept, recorded: the audience check (stricter than Duende's scope check alone), and this server's events — an inactive answer raises TokenIntrospectionFailureEvent with its reason where Duende raises TokenIntrospectionSuccessEvent for the request (0.36.0's choice). Claim values are now typed in the response, numbers as numbers, as Duende's ToClaimsDictionary writes them. IIntrospectionRequestValidator (with IntrospectionRequestValidationContext — plus this server's Issuer, which its token validator is asked with — IntrospectionRequestValidationResult and IntrospectionRequestValidationFailureReason) and IIntrospectionResponseGenerator, each with an open default; no public member of Duende's carries the failure-reason enum (its XML documentation names none), and here the result does. 0.173.0 — auth_time a number, amr an array, as in the token (decided 2026-11-03, breaking before 1.0): auth_time a JSON number whenever it is an integer, whatever its value type — so a reference token stored before 0.173.0, whose auth_time is typed string, is answered with a number too — and amr an array of its distinct values. Ours: Duende's ToClaimsDictionary answers one amr as a string, and a stored claim as its type says. |
| Revocation | ✅ | ✅ — 0.119.0: the request validator and response generator, and four answers this row had wrong. Measured from Duende 8.0.1's TokenRevocationEndpoint, TokenRevocationRequestValidator and TokenRevocationResponseGenerator. A request with no token is 400 invalid_request, and a token_type_hint other than access_token or refresh_token is 400 unsupported_token_type (RFC 7009 §2.2.1) — both were answered 200. A request with no client is 400 invalid_client, as one whose client fails — it was 401. The hinted kind is tried first and the other second, an access token first with no hint; only the client's own token. Revoking a refresh token removes the reference access tokens of its subject, client and session (IReferenceTokenStore.RemoveReferenceTokensAsync, Duende's) — here they had outlived it; to find them by session, a reference token's grant now records the session (Token.SessionId, from sid), so one stored before 0.119.0 is found only when no session is named. One TokenRevokedSuccessEvent, carrying the kind revoked (access_token or refresh_token, Duende's TokenType), where one was raised per grant type. ITokenRevocationRequestValidator, TokenRevocationRequestValidationResult, ITokenRevocationResponseGenerator, TokenRevocationResponse, and open defaults with Duende's protected virtual RevokeAccessTokenAsync/RevokeRefreshTokenAsync; OidcConstants.Errors.UnsupportedTokenType. Ours, 0.167.0: with PersistentGrants.ConsumedRefreshTokenGracePeriod on, a revoked refresh token takes its lineage with it (see IRefreshTokenService); off, the one handle, as Duende's. |
| Pushed authorization (PAR) | ✅ | ✅, and RFC 9126 §4 was missing from it until 0.37.0. A pushed request is stored as a reference hash, an expiry and the serialized parameters — nothing naming the client — so neither resolver could ask whether the client presenting a request_uri was the client that pushed it, and neither did. The reference was therefore a pure bearer value: whoever held one could start the authorization it stood for. The blast radius was real but bounded, and the bound is worth recording because it is what made the defect easy to miss — the restored parameters are the pusher's, so the code still went to the pusher's registered redirect URI rather than to whoever presented the reference. Fixed by comparing against the pushed parameters, which already record who pushed: no schema change, and no member whose only reader is this one comparison. A request presenting no client_id is refused rather than answered from the record, because a check a caller skips by omitting the parameter it compares is not a check. Applied in both readers — the authorize endpoint, and the return-URL parser that tells the login and consent pages which application is asking. Found by running the epic's own last coverage item, which claimed three grant paths compared the client and no test exercised any: there are five such artifacts, four compared, and the fixture is why nobody knew — the demo host registers one client per grant, so "the client this was issued to" and "the only client that could have been" were the same client. PAR was the exception that proved it: two clients there can push, and the helper that resumes a reference already took a client id, with every call passing the pusher's own. 0.54.0 closed the last silence in it, and the option that found it was one nothing set. PushedAuthorization.Required — the server-wide form of the per-client flag — is read at two live sites, the authorize endpoint's refusal and the discovery document's require_pushed_authorization_requests, and was written by no test on either. Client.RequirePushedAuthorization had been asserted both ways since PAR shipped, which is note 11 exactly: a rule applied to one of its halves, and the half with the convenient fixture is the one that gets it. The two are not restatements — a fixture proving the per-client flag can only use a client registered with it, so it says nothing about a client registered without one, which is the entire point of the server-wide switch. Writing that test found a defect in both halves: a client required to push that sent no reference was refused and recorded nowhere. Every other refusal funnelled through a helper whose own remarks claimed "every rejection in the resolver above goes through here, so a path added later cannot forget the event" — false for exactly one path, and the method's name is how it survived being read. It was RefusedReferenceAsync; a must-push refusal is not a reference refusal, so routing it through a method named for references looked wrong and leaving it out looked right. Renamed RefusedAsync and the refusal routed through it, so the claim is structural rather than remembered. The release also refuses to start when Required is true and EnablePushedAuthorizationEndpoint is false: every request refused for carrying no reference, no endpoint at which to obtain one, and — the reason it is a boot failure rather than a runtime surprise — nothing that can report it, because require_pushed_authorization_requests is published inside the branch that publishes the endpoint, so the client is told neither that pushing is required nor where to push 0.122.0 — the request validator and response generator, and push-time validation. Measured from Duende 8.0.1's PushedAuthorizationEndpoint, PushedAuthorizationRequestValidator, PushedAuthorizationResponseGenerator and StrictRedirectUriValidator. A push is validated as the authorize request it will become: Duende's validator runs IAuthorizeRequestValidator with AuthorizeRequestType.PushedAuthorization, so a request with an unregistered redirect URI, a scope the client may not have, no PKCE where required, a bad resource indicator or a bad request object is refused to the client that pushed it, 400 with the authorize endpoint's error — here it was stored and refused to the user's browser at redemption. The request object is resolved first, as the authorize endpoint resolves it, and the parameters are stored as pushed and resolved again when redeemed (Duende stores Raw). PushedAuthorizationOptions.AllowUnregisteredPushedRedirectUris (added): a client with RequireClientSecret may name any redirect URI in a request of type PushedAuthorization or AuthorizeWithPushedParameters (enum values 1 and 2, measured), and the configuration validator then requires it to register none. AuthorizeRequestType (Duende's), on IAuthorizeRequestValidator.ValidateAsync — before the cancellation token here, CA1068, where Duende's comes after the subject — ValidatedAuthorizeRequest and RedirectUriValidationContext; the authorize endpoint and the return-URL parser ask with AuthorizeWithPushedParameters for a redeemed reference. IPushedAuthorizationRequestValidator, PushedAuthorizationRequestValidationContext, PushedAuthorizationValidationResult (Duende's three constructors and ServerIssuedNonce), ValidatedPushedAuthorizationRequest, IPushedAuthorizationResponseGenerator, PushedAuthorizationSuccess, PushedAuthorizationFailure. Kept, recorded: a client's PushedAuthorizationLifetime only shortens the server's, where Duende takes it as it stands; the client_id ownership and RequireRequestObject checks stay before the authorize validator, with their descriptions. Not adopted, recorded for the whole-surface re-measure: Duende's IPushedAuthorizationService and IPushedAuthorizationSerializer (a data-protected store format), and RedirectUriValidationContext.RequestObjectValues. 0.177.0 (security): the exemption is for pushed requests only — a request object fetched by request_uri, stored behind the same kind of reference, is validated as Authorize on its return, where it had been treated as pushed (the review's confirmed finding: a link adding an unregistered redirect_uri to a published object got a code sent there). |
| Device authorization | ✅ | ✅ — 0.120.0: the request validator and response generator, and three answers this row had wrong. Measured from Duende 8.0.1's DeviceAuthorizationEndpoint, DeviceAuthorizationRequestValidator and DeviceAuthorizationResponseGenerator. An oidc client registered for the device grant, unauthorized_client otherwise. No scope asks for the client's allowed scopes, with offline_access when AllowOfflineAccess — here it asked for nothing, and a client with none is invalid_scope. A scope over InputLengths.Scope is invalid_request. Scopes the client may not have are invalid_scope, and identity scopes without openid are invalid_scope — here they were accepted. The response generator: the user code from the client's type or the default, regenerated while taken up to RetryLimit (as since 0.83.0); a local verification URL on the issuer, an absolute one as configured — here an absolute one was appended to the issuer; no verification_uri_complete without a user code parameter; the device code stored with client, scopes, description, lifetime and creation time. IDeviceAuthorizationRequestValidator, ValidatedDeviceAuthorizationRequest, DeviceAuthorizationRequestValidationResult (Duende's two constructors), IDeviceAuthorizationResponseGenerator, DeviceAuthorizationResponse, and open defaults. Not adopted, recorded: DeviceCode.IsOpenId, which Duende stores and this server reads off the authorized scopes at redemption. Noticed, for the whole-surface re-measure: this server's resource validator grants offline_access only to a client that both allows offline access and lists the scope; that has not been measured against Duende's. Measured 0.148.0: Duende's IsClientAllowedOfflineAccessAsync reads AllowOfflineAccess alone, so the listing is no longer required — which this row's own implicit offline_access needed, a client with the switch and no listing being refused the scope it was given. |
| Check session iframe | ✅ | ✅, and as of 0.64.0 the browser-state cookie it reads has a configurable name and domain (§10, Authentication). The name was a constant at four sites, one of them inside the iframe's own script, so two identity servers on one registrable domain wrote the same cookie and each read the other's state. The page and the Content-Security-Policy hash that permits its script are built together from one string, because a name substituted into the page but not into what was hashed yields a policy that blocks the script — an iframe that answers no poll, which reads exactly like a session that has not changed |
| Backchannel authentication (CIBA) | ✅ | ✅ as of 0.27.0 at connect/ciba, and the only endpoint here whose flag defaults to off. Every other one can answer with what this library alone knows; this one names a user who is not present, so it needs a host-supplied IBackchannelAuthenticationUserValidator and IBackchannelAuthenticationUserNotificationService. On by default would have every host that merely upgraded advertising an endpoint that answers with a 500, and UseIdentityServer() refuses to start with it on and either service missing — the treatment KeyManagement.Enabled = false with no credential gets, for the same reason. #95 0.121.0 — the request validator and response generator, and four answers this row had wrong. Measured from Duende 8.0.1's BackchannelAuthenticationEndpoint, BackchannelAuthenticationRequestValidator and BackchannelAuthenticationResponseGenerator. The endpoint's checks moved into an open BackchannelAuthenticationRequestValidator, in their order, and took Duende's four it did not make: a scope is required (invalid_request, Missing scope) and bounded by InputLengths.Scope; no openid is invalid_request, where it was invalid_scope; login_hint and login_hint_token are bounded — InputLengths.LoginHint and Duende's InputLengths.LoginHintToken (4000, added) — before the user validator is handed them; and a user validator error CIBA does not define is unknown_user_id (CIBA's own — access_denied, expired_login_hint_token, unknown_user_id, missing_user_code, invalid_user_code, invalid_binding_message — pass through, as in Duende). Storing, notifying and answering moved into BackchannelAuthenticationResponseGenerator; a notifier that throws is a server_error response with nothing left stored, as before. IBackchannelAuthenticationRequestValidator (default in the ASP.NET Core assembly, since it reads the issuer and the connection's certificate), IBackchannelAuthenticationResponseGenerator, BackchannelAuthenticationResponse (Duende's two constructors, Custom written into the response). The validation result gained Reason, this server's — what the host's failure event records, kept apart from what the client is told — and the validated request ClientNotificationToken, since Duende delivers by poll only. Kept, recorded: a requested_expiry that is not a positive number is ignored and a larger one capped at Ciba.MaximumLifetime, where Duende refuses one that is malformed or exceeds the client's lifetime; a client's PollingInterval only lengthens the server's, where Duende takes it as it stands; ping and push; and a request object with nothing at all beside it. Not adopted yet: Duende bounds a request object by InputLengths.Jwt (51200), which belongs with the JWT request validator release. Taken 0.124.0, with the rest of that validator: a signed request is read by IJwtRequestValidator with Duende's backchannel context (StrictJarValidation false, IncludeJti true), and one whose payload names request or request_uri is refused. The jti replay check stays this server's, with the object's expiry now read off the object, since the validated payload no longer carries exp. |
Every endpoint above is registered unconditionally, and until §1b was fixed they were routed by path
alone, so the Endpoints.Enable* options turned none of them off. EndpointRouter now consults
IsEndpointEnabled per request and a disabled endpoint falls through to a 404 — see §1b.
3. Grant types
| Grant | Duende | Here |
|---|---|---|
authorization_code + PKCE |
✅ | ✅ — 0.176.0, a security fix: PKCE at redemption, and the code taken once. Measured from Duende 8.0.1's TokenRequestValidator.ValidateAuthorizationCodeRequestAsync, the refusals from its IL: PKCE is validated when Client.RequirePkce or the code has a challenge — a RequirePkce client's challenge-less code is invalid_grant ("Client is missing code challenge or code challenge method", logged) — and otherwise a code_verifier is invalid_grant ("Unexpected code_verifier", logged): RFC 9700 §2.1.1/§4.8.2's downgrade, which this server accepted (a code without a challenge redeemed with any verifier, or none whatever RequirePkce said). Duende sends no error_description for either; ours says "Unexpected code_verifier." and "Missing code_challenge.". The code's scopes are validated again as the client's and an invalid one is invalid_scope, "Invalid scope." (Duende's; see the next row). Ours (security): the code is taken in one step (IAuthorizationCodeStore.TryRemoveAuthorizationCodeAsync, over IPersistedGrantStore.TryRemoveAsync), so of two redemptions at once one is answered and the other is invalid_grant, "Invalid code."; Duende reads, then removes. Ours, kept: PKCE is checked before the scopes, where Duende checks the scopes first, so a request wrong in both is answered invalid_grant here and invalid_scope there. |
client_credentials |
✅ | ✅ |
refresh_token |
✅ | ✅ — 0.176.0, a security fix: the scopes validated again. Measured from Duende 8.0.1's ValidateRefreshTokenRequestAsync (and its code grant): the scopes the grant carries go through the resource validator as the client's, and an invalid one — withdrawn from AllowedScopes, a disabled or deleted resource, offline_access once AllowOfflineAccess is off — is invalid_scope, "Invalid scope.". Until 0.176.0 the result was not read and the stored scopes were copied, so a withdrawn scope went on being issued until the refresh token expired; the device and CIBA grants already refused. Ours (security): a OneTimeOnly token is taken by the request answered with it (IRefreshTokenService.TryRedeemAsync, see that row). |
urn:...:device_code |
✅ | ✅ — 0.120.0: IDeviceCodeValidator, measured from Duende 8.0.1's DeviceCodeValidator and TokenRequestValidator.ValidateDeviceCodeRequestAsync. The poll's answers moved out of the token request validator into an open DeviceCodeValidator with Duende's DeviceCodeValidationContext (DeviceCode, Request, Result): unknown or another client's invalid_grant, expired expired_token, pending authorization_pending or slow_down, refused access_denied, an inactive user invalid_grant. The profile service is now asked as DeviceCodeValidation (ProfileIsActiveCallers.DeviceCodeValidation, Duende's) — it was asked as AuthorizationCodeValidation. The request carries the device code (ValidatedTokenRequest.DeviceCode, Duende's) and its session, and approving a device now records the browser's session on it, as Duende's HandleRequestAsync does through IUserSession — so device tokens carry sid, which they never did. A device_code over InputLengths.DeviceCode (100, Duende's) is invalid_grant. Kept, recorded: Duende throttles every poll first, before even the expiry check; here only a pending request is throttled, so a device whose user has decided collects the answer, for the reason the existing integration test gives. Expired and refused codes are removed when answered, which Duende leaves to the cleanup. |
password (ROPC) |
✅ | — deliberate non-goal. Removed by OAuth 2.1; the constants are deleted so it cannot be configured |
implicit |
✅ | — deliberate non-goal. Removed by OAuth 2.1; the constants, and the fragment / form_post response modes only it and hybrid need, are deleted. Corrected by the whole-surface re-measure (§12): form_post is not only implicit's and hybrid's — the OAuth 2.0 Form Post Response Mode applies to response_type=code, Duende supports it there, and ASP.NET Core's OpenID Connect handler asks for it by default. Its deletion was right for implicit and wrong for code; reopened as a gap. ✅ 0.130.0, Duende 8.0.1's, measured (Constants.AllowedResponseModesForGrantType, AuthorizeRequestValidator.ValidateCoreParameters, AuthorizeHttpWriter, DiscoveryResponseGenerator): query (default), form_post and fragment for code, exact match; anything else unsupported_response_type / "Invalid response_mode" to the error page. Success and error answers both go in the mode asked; form_post is Duende's page and headers (script hash orD0/…VJ8=, 'unsafe-inline' at CSP Level 1, no-store, Referrer-Policy: no-referrer) — the input names HTML-encoded too, where Duende's are not. Discovery lists form_post, query, fragment under Duende's DiscoveryOptions.ShowResponseModes. Ours already put iss on every answer (RFC 9207) where Duende's code flow does on success only; unchanged. JARM: neither has it. |
| CIBA | ✅ | ✅ as of 0.113.0 — 🟡 until then; poll end to end 0.27.0, ping 0.72.0, push 0.73.0 — urn:openid:params:grant-type:ciba, answering authorization_pending / slow_down / access_denied / expired_token exactly as the device grant does. Ping: Client.BackChannelTokenDeliveryMode and BackChannelClientNotificationEndpoint, client_notification_token accepted and bounded by InputLengthRestrictions, and a JSON POST of the auth_req_id bearing that token when the user answers — sent from CompleteRequestAsync after the answer is stored, so a client whose endpoint is down cannot cost the user their decision; the failure becomes BackChannelAuthenticationNotificationFailureEvent and the request stays redeemable by polling. The user is waiting on that call, which is why CibaOptions.NotificationTimeout is the same ten seconds every other outbound hop here uses. A mode this server cannot deliver is refused at the request, because there is no client-configuration validator in this tree (0.65.0's decision) and discovery advertises exactly what works — so ["poll", "ping"], and push stays off the list rather than being accepted and dropped, since it carries the tokens and those are not minted outside a token request yet. The red was observed rather than predicted: five tests, Build green and Test red, exactly the five named and no others. 0.72.1 corrected one sentence of that: the catch let an HttpClient timeout through as if the caller had cancelled, so an endpoint that hung rather than refused did fail the user's approval — the one case the fixture, which replaced the notifier, could not produce. The catch now re-throws only when the caller's own token is cancelled, the test drives the real HttpClient against an endpoint that never answers, and the endpoint's user-notifier catch got the same fix. Push, 0.73.0: the answer is redeemed through the same ITokenRequestValidator and ITokenResponseGenerator a poll goes through and POSTed to the client — the tokens with auth_req_id, or the refusal — so the second issuance path 0.72.0 declined to build does not exist; the identity token carries urn:openid:params:jwt:claim:auth_req_id and rt_hash; the token endpoint refuses a push client the grant (unauthorized_client, CIBA Core §11); and a push client whose tokens would be sender-constrained is refused at the request, because a pushed token can only be bearer. A failed push loses the tokens, which is the mode's trade and is recorded as the failure event. Not pushed: expired_token for a request nobody answered. id_token_hint, 0.74.0: validated by this server before the host sees it — signature, issuer, typ JWT, exactly one audience naming the client, azp naming the client (which is what refuses an access token whose typ a host made JWT), and a sub — with lifetime deliberately unchecked, because a hint names a user and the user still answers on their own device; the claims reach the host's validator as IdTokenHintClaims, and a validator answering with another subject is a server_error. user_code, 0.75.0: handed to the host's validator, which answers missing_user_code or invalid_user_code (both measured from the Duende.IdentityModel 8.0.1 package), and accepted only from a host that has set CibaOptions.UserCodeParameterSupported — a deliberate divergence, since Duende has no such option: a host whose validator does not check codes refuses the parameter rather than ignore a secret a client believes is gating the request, and its discovery says false. Never stored or logged. Signed request objects, 0.76.0: request verified with a JAR object's checks through one shared VerifyAsync, plus a required jti refused on replay through IReplayCache, and nothing but client authentication allowed beside it — stricter than Duende, which refuses only a parameter found in both places (measured). Still 🟡, for a reason this row did not carry until 0.76.0 measured it: Duende's backchannel request also takes acr_values (its BackchannelUserLoginRequest carries AuthenticationContextReferenceClasses, Tenant and IdP) and resource indicators, and this endpoint read neither. #96's own list is done; the row is not. Resource indicators arrived in 0.79.0, acr_values in 0.80.0 — bounded by InputLengths.AcrValues (invalid_request past it), split once each in the client's order, the first tenant: and idp: entries lifted into Tenant and IdP, all three on the stored request and on the context the approval page reads, which shows them; enforced by nothing, as in Duende, all measured from its BackchannelAuthenticationRequestValidator IL. What keeps the row 🟡 now, found by comparing the two request models member by member in 0.80.0: Duende's BackchannelUserLoginRequest also carries RequestedResourceIndicators and Properties, and its stored BackChannelAuthenticationRequest Description and Properties; this server's context and model have none of the four. An idp: outside Client.IdentityProviderRestrictions is dropped with a warning since 0.81.0, the property having arrived then (§10). 0.113.0 closed the row, and measuring the four members found what supplies two of them. RequestedResourceIndicators had in fact been on the stored request since 0.79.0 — this row's sentence was stale for that one. Duende's Properties are filled by ICustomBackchannelAuthenticationValidator, absent here and tracked nowhere until then; taken with its context, BackchannelAuthenticationRequestValidationResult and ValidatedBackchannelAuthenticationRequest (with the user as a subject id, since the user validator here answers with one), the default that accepts everything, and AddCustomBackchannelAuthenticationRequestValidator<T>. It runs last, on a valid request with its user resolved; a refusal is invalid_request with no description, as in Duende, the reason going to the failure event. Properties reach the stored request, the notifier and the approval context; the answer's ConsentResponse.Description reaches the stored request's Description. Where that description goes next — the tokens and grants the request yields — is the grant description chain, a separate gap found at the same time and 0.114.0's. #96 0.121.0: IBackchannelAuthenticationRequestIdValidator, measured from Duende 8.0.1's BackchannelAuthenticationRequestIdValidator: the poll's answers moved out of the token request validator into an open default with Duende's context (AuthenticationRequestId, Request, Result). The profile service is now asked with Duende's caller, BackchannelAuthenticationRequestIdValidation — it was asked with BackChannelAuthenticationValidation, a name and value of this server's own that no profile service written against Duende's constants would match (renamed; breaking). The token request carries the backchannel request (ValidatedTokenRequest.BackChannelAuthenticationRequest, Duende's) and its session, and approving a request now records the browser's session through IUserSession, as Duende's CompleteLoginRequestAsync does — so CIBA tokens carry sid, which they never did. Kept, recorded: throttling only while the request is pending, and expired or refused requests removed when answered. Noticed, for the whole-surface re-measure: Duende's completion adds auth_time and idp claims to the subject it stores; here the subject is rebuilt from its id at redemption. Duende's ProfileIsActiveCallers also has AuthorizeEndpoint, IdentityTokenValidation, AccessTokenValidation and SamlSsoEndpoint, which this server does not ask with. 0.160.0: the approver's sign-in (rows 33 and 37, decided 2026-10-14), measured from CompleteLoginRequestAsync: the session's user (IUserSession.GetUserAsync) cloned, auth_time — now, XMLSchema#integer — and idp — local — added when missing, stored as Duende's BackChannelAuthenticationRequest.Subject (as Duende's ClaimsPrincipalLite, inside the stored JSON — no schema change) and handed by the auth_req_id validator to the token request and the profile service, so CIBA tokens carry auth_time, idp, amr and acr; a request stored before has none and yields its subject id alone, as before. Ours: each identity copied (Duende's Clone shares them); the HTTP request's user for a session that cannot answer GetUserAsync; another user's sign-in, or none, recorded as the subject with now and local where Duende throws. |
| Token exchange (RFC 8693) | ✅ | ✅ impersonation 0.33.0, delegation 0.34.0 — and the row above it predicted how. It ships as a first-party IExtensionGrantValidator, registered by default, rather than a switch arm: validating the subject token needs the issuer and this server's own ITokenValidator, both of which live in the AspNetCore assembly, so a built-in arm meant threading an issuer through ITokenRequestValidator.ValidateAsync and all nine harnesses that construct it — to reach a value the endpoint computes one line after validation. The seam 0.28.0 built is what made the smaller change possible, which is the argument that release made for building it first. issued_token_type is emitted for this grant and no other, as RFC 8693 §2.2.1 requires. Only the subject crosses the exchange: every other claim is rebuilt for the exchanging client from its own registration and the profile service, so a claim the original audience was trusted with does not travel to one that was not; and a subject token naming no user is refused, because there is nobody to impersonate and the token it would produce is one the client could have asked for directly. Scopes go through the exchanging client's registration exactly as every other grant's do, so holding a user's token widens nothing. Delegation completed the row in 0.34.0, and the shape of the deferral is worth keeping: 0.33.0 refused actor_token outright rather than accepting and ignoring it, so the half that shipped never claimed to be the whole. actor_token is now honoured, the issued token carries act, and chained delegation nests rather than replaces (§4.1) — a second hop overwriting the first would leave three services reading as one, and read as a complete answer rather than a missing one. Who may act for whom is a host decision with no default: IDelegationPolicy, the treatment CIBA's user validator got in 0.27.0, because that question is a fact about an organisation rather than about OAuth, and a library guessing it would either refuse delegations that are fine or permit ones that are not — only the second being invisible until it matters. With no policy registered actor_token is refused exactly as in 0.33.0, so the release is additive. act reaches the token through one entry in the allowlist DefaultClaimsService copies, which already preserved ValueType — so it lands as a JSON object rather than a string of JSON, and it belongs beside amr and idp on its merits, all three recording how the token was obtained. One breaking edge, recorded rather than discovered: a host that had written its own token-exchange validator collides with this one and fails at startup, by the rule guarding validator collisions since 0.28.0. #108, #111 0.176.0, a security fix — Ours (security), no Duende counterpart: the subject token and the actor token must validate as access tokens (so an identity token or a logout token is refused, see the custom validation hooks row) and name client_id and scope, as every token the token endpoint issues does; otherwise invalid_grant. Until 0.176.0 an identity token a relying party held was exchanged for an access token as its user (RFC 8693 §2.1, RFC 9068). |
| Resource indicators (RFC 8707) | ✅ | ✅ as of 0.94.0: several indicators per grant, one per token request, measured from Duende 8.0.1's AuthorizeRequestValidator, BackchannelAuthenticationRequestValidator, DefaultResourceValidator and TokenRequestValidator. The authorization, pushed and backchannel endpoints read every resource value (a request object's array included — Duende adds each claim with NameValueCollection.Add), refuse any too long, malformed, or naming no enabled API the scopes reach as invalid_target (Duende's three messages), and the code, backchannel request and refresh token remember them all. A token request may name one, which must be among them ("does not match any resource indicator in the original … request"); naming none is FilterByResourceIndicator(null): every API the scopes reach except those with RequireResourceIndicator. Carried through every single-string layer joined with a space, as scope is, since an absolute URI cannot contain one. Corrected in 0.94.0, and the correction is the finding: 0.78.0 refused as invalid_scope a scope the named API did not expose, and gave a token request naming none the grant's one indicator, and recorded both as measured. Decoded, Duende's ValidateScopeAsync finds an API scope by name alone (FindApiScope) and indicators filter only the API resources kept — the invalid_scope came from the log line "Scope {scope} not found in store or not supported by requested resource indicators", which fires for a scope not found at all — and the code grant filters an unnamed exchange as above. A message read as a rule is the same mistake as a property read as an implementation: the words were Duende's, the behaviour was invented. History: token endpoint 0.77.0, authorization endpoint 0.78.0, backchannel endpoint 0.79.0, one indicator per grant until 0.94.0. One resource at the token endpoint, for every grant but the device flow (refused there, as Duende does): an absolute URI with no fragment, bounded by InputLengthRestrictions.ResourceIndicator (512), naming an enabled API the grant already reaches, or invalid_target. It narrows the access token only — that API as the sole audience, only its API scopes — and the refresh and identity tokens keep the whole grant. ApiResource.RequireResourceIndicator keeps an API out of any audience that did not name it. At the authorization endpoint, 0.78.0: one resource, recorded on the code (RequestedResourceIndicators) and carried to the refresh token (AuthorizedResourceIndicators), so every later token request is held to it — another API is invalid_target, none gets the authorized one. An indicator naming an API that exposes none of the requested scopes is invalid_target, one whose API leaves another requested scope elsewhere is invalid_scope, both as Duende's messages read. One indicator, not several — a deliberate divergence from RFC 8707 and Duende: every layer of the authorization pipeline carries a parameter as one string, so two are refused where they can still be counted (endpoint, PAR, request object) rather than joined with a comma a URI may contain. At the backchannel endpoint, 0.79.0: the same rules, through the same ResourceIndicatorValidation the authorization endpoint now calls, recorded on the CIBA request and carried to the poll, the push and the refresh token. Still 🟡 for one reason: several indicators per grant, which RFC 8707 and Duende allow and this pipeline's single-valued parameters do not. Found on the way: ApiResource.Enabled is read by introspection's secret check and, now, by an indicator naming the API — and by nothing that builds an audience without one, so a disabled API is still issued tokens for. Recorded, not fixed here — fixed in 0.93.0, with the rest of Resource.Enabled: see the IResourceStore row. Before 0.77.0 this row read: absent, and absent from this matrix until 0.76.0. Duende 8.0.1 takes resource at the authorize, token and backchannel endpoints — ValidatedAuthorizeRequest, ValidatedTokenRequest and ValidatedBackchannelAuthenticationRequest each carry requested resource indicators, ResourceValidationResult.FilterByResourceIndicator narrows a grant to one, and InputLengthRestrictions.ResourceIndicatorMaxLength bounds them, all measured from the 8.0.1 package. Nothing in this tree reads a resource parameter. Found while checking whether CIBA's row could close: no row named the capability, so no sweep of rows could have found it — the shape note 26 and the 0.63.0 limit (an absence has no name to grep for) describe, at the scale of a whole RFC. 0.116.0: an indicator-narrowed access token's scope keeps that API's scopes and offline_access and no longer the identity scopes, as Duende's FilterByResourceIndicator filters ParsedScopes (see the parameterized scopes row). |
Extension grants (IExtensionGrantValidator) |
✅ | ✅ as of 0.28.0. Client.AllowedGrantTypes is a collection of plain strings, so a host could always register a client for any grant it liked and never make one work — the token endpoint's set of grants was a switch and everything outside it fell to the default arm. A registered validator now makes its grant type supported, routable and advertised. What it decides is who the token is for; what the token may reach still goes through the client's registration and the resource store, so writing a grant cannot widen a client. Two collisions — with a built-in, or with another validator — fail at startup, because either would leave a validator registered, advertised and never called. #98 0.148.0 — what the grant is handed and what it hands back, measured from Duende 8.0.1's TokenRequestValidator.ValidateExtensionGrantRequestAsync and ExtensionGrantValidator.ValidateAsync: the requested scopes are validated before the grant is asked (here they were after; the refusal still comes after the grant is asked, so a request both refused by the grant and out of scope answers in the grant's terms, as before — ours, by decision), and the grant is handed the validated request as ExtensionGrantValidationContext.Request (client, scopes, resources, Raw, options, Secret), the same object the tokens are issued for, so a lifetime or client claim it sets reaches them. ExtensionGrantValidationResult.CustomResponse (settable, Duende's GrantValidationResult.CustomResponse) goes back with the success and with the refusal, as Duende's Valid(customResponse)/Invalid(error, description, customResponse); an inactive subject is refused without it, as Duende's. Ours, kept: the result's static factories rather than Duende's five constructors, and the openid-needs-a-user rule. 0.160.0: with TokenRequestDefaultScopesFromResourceStore (off by default, decided 2026-10-14), a request naming no scope asks for the identity resources and API scopes the store has among the allowed ones, plus offline_access when the client allows offline access (Duende's ValidateRequestedScopesAndResourcesAsync with ignoreImplicit… false), and one granted no scope — a client with no allowed scopes among them — is invalid_scope; client_credentials likewise asks for the store's API scopes alone. |
Custom validation hooks (ICustomAuthorizeRequestValidator, ICustomTokenRequestValidator, ICustomTokenValidator) |
✅ | ✅ as of 0.115.0 — found untracked 0.112.0, three of the eighteen validation interfaces absent from src/ and from this matrix; the CIBA hook, the nineteenth, closed 0.113.0 in the CIBA row. Measured at Duende 8.0.1's call sites. Authorize: AuthorizeRequestValidator.ValidateAsync asks the hook last, after every built-in check, with new AuthorizeRequestValidationResult(request), and answers a refusal with Invalid(request, Error ?? "invalid_request", ErrorDescription) — so here a refusal goes back to the client's redirect URI with the host's error and description, and on success the request this server validated goes on, as the host changed it, not one its result substituted. Token request: TokenRequestValidator.RunValidationAsync asks the hook for every grant once the grant is valid and returns its result as it stands; TokenEndpoint answers a refusal with Error(error, description, CustomResponse) and raises TokenIssuedFailureEvent, and TokenResponseGenerator copies CustomResponse onto every grant's success response (the shared ProcessTokenRequestAsync and the refresh path), both writers putting it at the top level through [JsonExtensionData]. Asked here by the token endpoint rather than the request validator, after the DPoP proof and confirmation are settled, because Duende's request validator has settled them by then and this server's endpoint settles them after its validator returns. Where a custom member collides with one this server writes, this server's stands — Duende's writer emits the name twice and leaves which one a client reads to its parser; that is a divergence on purpose and tested. Token: TokenValidator.ValidateAccessTokenAsync and ValidateIdentityTokenAsync ask ICustomTokenValidator last, for a valid token only, and return its result; here after the session check, for every access token (userinfo, introspection, token exchange, local API — JWT and reference alike) and every identity token hint. Duende's takes one TokenValidationResult for both; this server keeps AccessTokenValidationResult and IdentityTokenValidationResult apart, so each method takes its own. DefaultCustomTokenValidator is open with virtual methods, as Duende's is, without its protected Logger, Profile and Clients fields, which Duende's own default never reads; no builder method, as in Duende. Surface: both results gained Duende's two constructors, CustomResponse (settable, Duende's shape), and Duende's ValidatedRequest name for what was Request — breaking, pre-1.0; both validated requests gained Raw, the parameters as this server reads them, a map of single strings where Duende's is a NameValueCollection, as the CIBA hook's already was. Not adopted: TokenRequestValidationResult.DPoPNonce, because a server-issued nonce here comes from the DPoP proof validator, not from the request validator's result. Not yet reached, recorded for the endpoint validators to come (0.118.0+): Duende's PushedAuthorizationRequestValidator and OidcReturnUrlParser both run the whole authorize validator, so its hook sees a pushed request when it is pushed and on every return-URL parse — here a pushed request meets the hook when it is used (as of 0.122.0 also when it is pushed, the PAR validator running the authorize one), and the return-URL parser re-validates nothing (taken 0.164.0, plan row 40: the parser asks the authorize validator with the session's user and builds AuthorizationRequest from the validated request, so the hook runs on every parse and what it changes reaches the login page); and EndSessionEndpoint reads its id_token_hint without ITokenValidator, so the token hook does not see it, where Duende's end-session validator asks ValidateIdentityTokenAsync. Taken 0.123.0: the end-session validator asks the token validator, and the hook sees the hint. 0.148.0 — what the token hook can reach, measured from Duende 8.0.1's ValidatedRequest.SetClient and its readers (DefaultTokenService.CreateAccessTokenAsync, DefaultClaimsService.GetAccessTokenClaimsAsync, TokenResponseGenerator.ProcessTokenRequestAsync/ProcessRefreshTokenRequestAsync): ValidatedTokenRequest.AccessTokenLifetime, AccessTokenType and ClientClaims (a copy of the client's claims) are the request's own, read for the access token's lifetime and form, its client claims, expires_in and a refresh's renewed lifetime, carried to the token service on TokenCreationRequest; Secret (the parsed credential, through a new ITokenRequestValidator.ValidateAsync(client, secret, parameters) overload the token endpoint calls — its default implementation sets it on what the old overload returns) and AuthorizationCode are there for a hook, read by nothing in Duende's or here. Ours: the three settings read through to the client until set, where Duende's SetClient copies them; AccessTokenType is not set on the identity token, where Duende sets it to no effect. Not taken: ClientId, ProofType, RequestedScopes, UserName, AuthorizationCodeHandle, CodeVerifier — the first four are this request's Client.ClientId, DPoPKeyThumbprint/Confirmation, Scopes and the password grant's; the last two are a credential and its verifier, which a hook has no use for and a log should not see. 0.174.0 — the codes a refused access token carries, measured from Duende 8.0.1's TokenValidator.ValidateJwtAsync and ValidateReferenceAccessTokenAsync: expired_token for a SecurityTokenExpiredException, invalid_token for every other JWT failure (signature, issuer, a token that does not parse) and for a handle the store does not have (RFC 6750 §3.1). DefaultTokenValidator answered invalid_request for all of them. Each caller maps the result — userinfo invalid_token (with Duende's description for an expired token), the local API invalid_token, introspection active: false, token exchange invalid_grant — so the change reaches a host calling ITokenValidator itself or reading its result. Kept: an empty token is invalid_request (Duende's looks it up as a handle and answers invalid_token); there is nothing presented to be invalid. 0.176.0, a security fix — only an access token is an access token, measured from Duende 8.0.1's TokenValidator.ValidateJwtAsync: ValidTypes = [AccessTokenJwtType] when that option is set, taken; until then no typ was read, and an identity token or a back-channel logout token validated as an access token at userinfo, introspection, the local API and token exchange (RFC 9068 §4, RFC 8725 §3.11). Ours (security): where the type cannot tell the kinds apart — AccessTokenJwtType empty, JWT, or LogoutTokenJwtType — a JWT without client_id, or with nonce, azp or events, is invalid_token; Duende with such a type accepts a JWT without client_id that has no sub, and throws on one that has. |
Parameterized scopes and redirect URI validation (IScopeParser, IRedirectUriValidator) |
✅ | ✅ as of 0.116.0 — two more of the eighteen validation interfaces found untracked 0.112.0, and both reached further than their names. Scope parsing, measured from Duende 8.0.1: DefaultScopeParser.ParseScopeValues hands each value to a virtual ParseScopeValue that does nothing, so a value is its own name; ParseScopeContext.SetParsedValues, SetError and SetIgnore answer for one value; a refused value is a ParsedScopeValidationError naming it, an ignored one disappears, and a null collection is one error named null. DefaultResourceValidator.ValidateRequestedResourcesAsync parses first and, on any error, returns the refused raw values as the invalid scopes; ValidateScopeAsync then checks each parsed name against the client and the store and keeps the parsed value, refusing by raw value. ResourceValidationResult gained ParsedScopes and Duende's two constructors, and GrantedScopes became Duende's RawScopeValues — the raw value of each parsed scope, so transaction:123 is what is granted, stored on the code, consented to and written into the access token's scope (Duende's DefaultClaimsService, measured). Found measuring it: FilterByResourceIndicator keeps, for a named API, only the parsed values whose name is one of that API's scopes, plus offline_access — so the identity scopes leave an indicator-narrowed access token's scope, which here had kept them since 0.94.0; taken as measured. Consent, measured from DefaultConsentService.RequiresConsentAsync, in Duende's order: client does not require consent; no scopes, no consent; remembering forbidden; a parameterized scope (parsed name ≠ raw value) is asked about every time; so is offline_access; then the store. The two bold rules were not here — the second is a behaviour change for remembered consent, taken as measured. IConsentService.RequiresConsentAsync takes ParsedScopeValues, Duende's type; its row below said plain strings sufficed because there were no parameterized scopes, which stopped being true. The consent and device pages build an API scope's row from its parsed value as Duende's consent template does (Duende.Templates 1.3.1, CreateScopeViewModel): the raw value posted back, the display name followed by : and the parameter. Nothing in Duende's assembly reads ParsedParameter; the template is its one reader, and here the shared consent rows are. Redirect URIs, measured: IRedirectUriValidator has the string overload, a context overload whose default implementation answers as the string one, and the post-logout check; Duende asks it from AuthorizeRequestValidator.ValidateClientAsync and EndSessionRequestValidator, and here also from the return-URL parser, which re-checks a request's redirect URI for the pages where Duende's runs the whole authorize validator. StrictRedirectUriValidator compares exactly where Duende's StringCollectionContainsString is OrdinalIgnoreCase — a divergence on purpose, RFC 9700 §2.1 requiring exact string matching — and its helper is protected static where Duende's is instance (CA1822; a derived class calls it the same). StrictRedirectUriValidatorAppAuth, measured byte for byte: the strict answer, or for a client with RequirePkce that registered exactly http://127.0.0.1, a URI split on : into exactly three parts, http and //127.0.0.1 compared ordinally, the port read up to the first / or ? and below 65536 (0x10000 in the IL); the same for post-logout URIs. AddScopeParser<T>, AddRedirectUriValidator<T>, AddAppAuthRedirectUriValidator(). Not adopted, with reasons: RedirectUriValidationContext's constructor from a validated request — this server checks the redirect URI before it builds one, since every refusal after that is sent to it; ParseScopeContext's and the parsed types' setters — read-only here, built through their constructors; ResourceValidationResult.Succeeded requiring at least one parsed scope and InvalidResourceIndicators, which belong with IResourceValidator's own request shape, for the whole-surface re-measure. Left for the PAR validator (0.118.0+): PushedAuthorizationOptions.AllowUnregisteredPushedRedirectUris — found untracked here, read only by StrictRedirectUriValidator for a confidential client's pushed request — and the context's AuthorizeRequestType and RequestObjectValues, which it reads. Taken 0.122.0: AllowUnregisteredPushedRedirectUris and AuthorizeRequestType; RequestObjectValues is still not adopted. 0.155.0: IResourceValidator.ValidateRequestedResourcesAsync and ResourceValidationRequest (a default interface method over ValidateAsync, Duende's interface having only it) judge the resource indicators beside the scopes into ResourceValidationResult.InvalidResourceIndicators, which Succeeded counts — Duende's rules, measured: the APIs the scopes reach kept when they do not require an indicator or are named, an indicator naming none of them invalid, an API requiring one left out without, a failed result granting nothing. The authorize and backchannel validators ask it. Ours: a request wrong in both ways is refused for its scope first, as before; Duende answers invalid_target. Not taken: Succeeded requiring a granted scope, with the token request's default scopes (release 33). 0.160.0: Succeeded's granted-scope rule taken at the token request, for client_credentials and extension grants, behind IdentityServerOptions.TokenRequestDefaultScopesFromResourceStore (off, decided 2026-10-14), with Duende's default scopes; the property itself unchanged — every other reader refuses a request with no scope before it. |
Configuration validators (IClientConfigurationValidator, ISamlServiceProviderConfigurationValidator) |
✅ | ✅ as of 0.117.0 — the last two of the eighteen found untracked 0.112.0 that are not per-endpoint request validators, and 0.65.0's decline re-measured and reversed. 0.65.0 declined InvalidClientConfigurationEvent because nothing validated client configuration; that was true of this tree and not of Duende, which has the validator, a validating store and the event. Client, measured from Duende 8.0.1: DefaultClientConfigurationValidator.ValidateAsync checks an oidc client — any other protocol is not checked — by seven virtual methods in order, stopping at the first refusal: grant types (none), lifetimes (access or identity token ≤ 0; device code ≤ 0 for the device grant, which does not return, so a refused refresh lifetime after it overwrites the message — reproduced; absolute or sliding refresh < 0), redirect URI (an authorization_code, hybrid or implicit client with none, unless AllowUnregisteredPushedRedirectUris and a confidential client — that exemption taken 0.122.0, with the option), CORS origins (IsUri, AbsolutePath == "/", no trailing /; a blank one named as such), URI schemes (every redirect and post-logout URI against InvalidRedirectUriPrefixes, the last refused standing — here judged by IsAllowedRedirectTarget, the endpoints' own check, which normalizes before Duende's StartsWith(OrdinalIgnoreCase)), secrets (per grant: required and none, unless implicit; client credentials with none required) and properties (nothing). Duende's messages throughout. ValidatingClientStore<T> answers a refused client as none, logs it and raises InvalidClientConfigurationEvent (ErrorStart + 1, Duende's offset; ClientName falls back to unknown name); AddClientStore<T> registers T and puts it in front, and AddInMemoryClients goes through AddClientStore, as Duende's do (decoded from the MethodSpec instantiations); the Dapper configuration store is read through it too, as Duende's EF store is. NopClientConfigurationValidator; AddClientConfigurationValidator<T>. Services.KeyManagement.ClientConfigurationValidator, measured: the default's checks and an AccessTokenLifetime beyond KeyRetirementAge.TotalSeconds; nothing in Duende constructs it, so a host opts in, as here. KeyManagementOptions.KeyRetirementAge (RotationInterval + RetentionDuration, measured) added for it. Every sample client passes the default, which the integration suite running green says; two fixtures that poison a client with an executing redirect scheme now turn the validator off, because what they test is the runtime refusal that stands behind a store no validator reads. SAML, measured: the default checks EntityId (IsNullOrWhiteSpace), the assertion consumer service URLs (at least one; each HTTP-POST), the allowed scopes (at least one) and the lifetimes (AssertionLifetime, RequestMaxAge positive; ClockSkew non-negative); the validating store raises InvalidSamlServiceProviderConfigurationEvent (ErrorStart + 3). Here the default keeps 0.71.0's rules — one assertion consumer service URL, absolute, http(s), HTTPS when required; the logout URL likewise — and takes Duende's whitespace entity id; its messages are SamlServiceProviderProblems codes, as the log has carried them. Duende's other three checks read members this server's SamlServiceProvider does not have — several assertion consumer services with bindings, AllowedScopes, per-provider lifetimes — recorded for the whole-surface re-measure, as is the store's non-generic, keyed shape against Duende's ValidatingSamlServiceProviderStore<T>. 0.65.0 read InvalidSamlServiceProviderConfigurationEvent as a rival name for SamlLogoutNotificationFailureEvent and declined it; it is not one — Duende raises it from the validating store — and since 0.71.0 this server had that store and raised nothing from it. Found untracked measuring it, for the whole-surface re-measure: IClientStore.GetAllClientsAsync and Duende's GetAllSamlServiceProvidersAsync, which both validating stores also validate; AddClientStoreCache<T> / CachingClientStore<T>, which Duende puts over the validating store; and IIssuerPathValidator / DefaultIssuerPathValidator, a public validation interface the 0.112.0 sweep missed. Taken 0.125.0, with the RFC 8414 endpoint that is its only caller (see that row). |
JWT request validator (IJwtRequestValidator) |
✅ | ✅ as of 0.124.0, measured from Duende 8.0.1's JwtRequestValidator (IL decoded) and its two callers. Every request object — request at the authorize endpoint and in a push, a fetched JAR request_uri, a signed CIBA request — is read by one IJwtRequestValidator, where RequestObjectValidator verified them itself. JwtRequestValidationContext (Client, JwtTokenString, bool? StrictJarValidation, IncludeJti), JwtRequestValidationResult (Payload, a failure setting IsError alone, as Duende's does; the callers answer invalid_request_object), and an open JwtRequestValidator with Duende's protected virtual GetAudienceUri, GetKeysAsync, ValidateJwtAsync and ProcessPayloadAsync (the claims less Duende's JwtRequestClaimTypesFilter — iss, aud, exp, nbf, iat, and jti unless asked for). Three refusals new here: an object longer than InputLengths.Jwt; under StrictJarValidation, one not typed oauth-authz-req+jwt (see the strict JAR row, which was wrong); and one whose payload names request or request_uri — which until 0.124.0 was merged into the parameters as if it were one, a signed request_uri left for the next step to make sense of. Kept, recorded: the clock skew is a fixed five minutes, where Duende reads JwtValidationClockSkew (whose default there is five minutes and here zero — see that row); a key whose secret has expired vouches for nothing, where Duende's GetKeysAsync (ClientExtensions, measured) filters by type alone; and off a request (no HttpContext) there is no audience and the object is refused. Duende's second constructor, (string audienceUri, ILogger), is not taken: it exists for a validator used outside a request, which nothing here does. Noticed, for the whole-surface re-measure: Duende's GetKeysAsync also turns X509CertificateBase64 secrets into keys; this server has no such secret type at all, a gap wider than this validator (private_key_jwt reads secrets too). |
Validated-request abstraction (IValidatedRequest) |
✅ | ✅ as of 0.155.0 — 🟡 from 0.126.0 until then. The eighteenth of the validation interfaces found untracked 0.112.0, and the last. Duende 8.0.1's IValidatedRequest (Application, Subject, SessionId, IssuerName, Options, measured), implemented by ValidatedTokenRequest since 0.126.0: Application the client, IssuerName its Issuer (this server's name for it, kept), Options the options its validator validated it under. That is the request a profile service is handed (see the IProfileService row). SAML's ValidatedAuthnRequest became one at 0.141.0. 0.155.0, the other five (release 29): ValidatedAuthorizeRequest, ValidatedPushedAuthorizationRequest (not a subclass of the authorize request here, as it is in Duende), ValidatedEndSessionRequest, ValidatedDeviceAuthorizationRequest and ValidatedBackchannelAuthenticationRequest, each written where it can be true rather than declared: Options by every validator (Duende's authorize, device and backchannel validators write it, measured); IssuerName by the authorize and device endpoints and the push, end-session and backchannel validators; the authorize request's SessionId by its endpoint for a signed-in user, as Duende's ValidateOptionalParametersAsync reads it, and the code is issued with it; no subject or session on a push or a device request; the backchannel request's subject a principal holding its SubjectId as sub. With them ValidatedAuthorizeRequest.RequestObjectValues and RedirectUriValidationContext.RequestObjectValues — the signed payload less the JWT's own claims, as Duende filters it — handed to the validator through a new IAuthorizeRequestValidator.ValidateAsync overload, since the endpoint resolves the request object here. Ours, recorded: a custom authorize request validator does not see the issuer or the session, which the endpoint writes after it; the other four carry the issuer and options where Duende leaves them unset; an anonymous authorize request's session is null, Duende's ""; a request object fetched by reference does not carry its claims past the pass that stores it. |
ValidatedAuthorizeRequest.LoginHint, DisplayMode, AuthenticationContextReferenceClasses, RequestObject, PushedAuthorizationReferenceValue; ValidatedAuthorizeRequestExtensions (GetPrefixedAcrValue, RemovePrefixedAcrValue, GetIdP, RemoveIdP, GetTenant, GetAcrValues, RemoveAcrValue, AddAcrValue) |
✅ | ✅ as of 0.164.0 (plan row 40, gap G4 of the final re-measure), Duende 8.0.1's, measured. The validator reads display (the four), login_hint and acr_values (each once) before a custom validator is asked, and removes an idp: hint outside the client's restrictions; the authorize endpoint writes the request object's JWT and the pushed reference value after the validator answers. The helpers match idp:/tenant: ordinally, first match answering, and keep the request's acr_values parameter in step. Ours: RequestObject and PushedAuthorizationReferenceValue are not seen by a custom validator (this server's endpoint resolves both before validating); the RemoveIdP warning is not logged. |
4. Client authentication
| Method | Duende | Here |
|---|---|---|
client_secret_basic |
✅ | ✅ TokenEndpoint.cs:73 |
client_secret_post |
✅ | ✅ |
private_key_jwt |
✅ | ✅ — and accepted once as of 0.131.0, Duende 8.0.1's PrivateKeyJwtSecretValidator, measured: a jti is required and one the replay cache has seen is refused, kept until five minutes past the assertion's expiry. Until then a captured assertion authenticated its client again until it expired. Found measuring re-measure release 5; the plan had not listed it 0.165.0 (breaking, plan row 41): the parsed credential's type, ParsedSecretTypes.JwtBearer, is Duende's urn:ietf:params:oauth:client-assertion-type:jwt-bearer (measured; "JwtBearer" until then), and with it the success event's AuthenticationMethod. |
| mTLS | ✅ | ✅ both registration rules, certificate-bound tokens checked at this server's own resource endpoints as of 0.45.0, path-based endpoint aliases — see §1a and §1e row 10 |
ISecretParser / ISecretsListParser abstraction |
✅ | ✅ with ISecretValidator / ISecretsListValidator; a host adds a scheme by registering a parser and a validator |
5. Sessions and logout
| Feature | Duende | Here |
|---|---|---|
| Cookie session | ✅ | ✅ (opt-in via AddIdentityServerCookieAuthentication() — a deliberate divergence, documented); lifetime, sliding and SameSite on AuthenticationOptions at Duende's defaults as of 0.101.0 (§10, Authentication) |
| Server-side sessions | ✅ IServerSideSessionStore, ISessionManagementService, ISessionCoordinationService, IUserSession, SessionQuery, IServerSideTicketStore |
✅ all six as of 0.12.0 — IServerSideSessionStore (with SessionFilter, SessionQuery, QueryResult and the ServerSideSession row), IServerSideTicketStore, ISessionManagementService, ISessionCoordinationService and the existing IUserSession — registered by AddServerSideSessions() and opt-in, because every signed-in browser becomes a row to write, read and sweep. A session can be enumerated and ended from outside the browser holding it, and coordination notifies the clients before the grants are revoked rather than after. Completed in 0.13.0 by the two things 0.12.0 left it short of: a Dapper store, without which sessions did not survive a restart or reach a second instance (§7, #66), and the sweep that removes them when they expire, without which nothing ever did (§1e row 8, #68). 0.119.0: IServerSideTicketStore.GetSessionsAsync(SessionFilter) and UserSession, Duende's, for the userinfo validator — expired and unreadable rows skipped, as RetrieveAsync treats them; UserSession without Duende's Issuer and ClientIds, the client list being on the ticket's properties. Duende's ticket store also has QuerySessionsAsync and GetAndRemoveExpiredSessionsAsync, recorded for the whole-surface re-measure. 0.158.0: IUserSession.GetUserAsync (the session's own cookie scheme authenticated: a ticket that succeeded for an authenticated identity, else null — not HttpContext.User) and RemoveSamlSessionAsync (every record for the entity id, compared ordinally; the saml_session_list item removed when empty; nothing for nobody signed in; ArgumentNullException for a null id), measured from Duende 8.0.1's DefaultUserSession, as default interface methods, so a host's session keeps compiling and throws NotSupportedException when asked one it does not implement (Duende's have no default). Nothing here calls either: Duende's twenty callers of GetUserAsync read the session's scheme, this server's endpoints read it as before (decided 2026-10-14); Duende calls RemoveSamlSessionAsync from nowhere. Ours: removing a provider the session never reached leaves the cookie alone, where Duende's re-issues it 0.162.0: ISessionManagementService.RemoveSessionsAsync(RemoveSessionsContext) and RemoveSessionsContext (SubjectId, SessionId, ClientIds; RemoveServerSideSession, SendBackchannelLogoutNotification, RevokeTokens, RevokeConsents, each true by default), measured from Duende 8.0.1's DefaultSessionManagementService and done in its order: the grants (every type with both revocations, user_consent alone, or refresh tokens, reference tokens and codes alone, for the named clients), then each live session's ticket clients, narrowed to the named ones, told terminated, then the rows; an empty ClientIds narrows no revocation and tells no client, as Duende's. An overload beside the filter one, which is unchanged, and a default interface method throwing NotSupportedException; a new DefaultSessionManagementService constructor (ticket store, grant store, back-channel logout service), the old one kept and refusing a context. Ours: a context naming neither subject nor session refused up front (Duende's revokes what the grant filter alone matches, then throws); no ciba type (CIBA requests are not grants here); one client and one type per removal; SessionRevokedEvent and GrantsRevokedEvent raised, Duende's raising none |
| Back-channel logout | ✅ IBackChannelLogoutService, IBackChannelLogoutHttpClient, ILogoutNotificationService |
✅ IBackChannelLogoutService + IBackChannelLogoutHttpClient. The notification context is read back through ILogoutNotificationContextFactory rather than Duende's ILogoutNotificationService, which also computes the front-channel half; here that stays on the interaction service. Sent on every path that ends a session as of 0.41.0 — 0.40.0 said "every path" and meant the three it had counted; the SAML single-logout endpoint was a fourth, and it dropped the cookie and raised the event while sending nothing. Counting the paths and then shipping the sentence is how a claim like this one comes to be wrong; it is worth reading the two releases together. Before 0.40.0 — until then this row read "sent from the logout page, so a host replacing the UI wholesale must call the service itself", which was true and was the defect. The end-session endpoint is not a host replacing the UI; it is this server, and it did not call the service either. A relying party ending the session got the cookie dropped and nothing sent. The tenth method note, on this row: a row that states its own consumer is making a checkable claim, and this one named a single consumer for a thing that has two callers. 0.129.0 — why the session ended, Duende 8.0.1's, measured: the logout token carries logout_reason (IdentityServerConstants.ClaimTypes.BackChannelLogoutReason, BackChannelLogoutReasons) from LogoutNotificationContext.LogoutReason (LogoutNotificationReason) — user_logout at the sign-out service, session_expiration from the sweep through Duende's ISessionCoordinationService.ProcessExpirationAsync (a default member, so older implementations compile), terminated from RemoveSessionsAsync; absent when unknown. Diverges on one name: Duende's ProcessLogoutAsync is called at a browser's sign-out, ours only by RemoveSessionsAsync (our sign-out service notifies directly), so ours says terminated. BackChannelLogoutRequest was not taken then — the reason went straight from the context into the token. 0.147.0 — the service opened (re-measure release 14, decision 5), Duende 8.0.1's DefaultBackChannelLogoutService and BackChannelLogoutRequest, measured: SendLogoutNotificationsAsync(requests), CreateFormPostPayloadAsync, CreateTokenAsync, CreateClaimsForTokenAsync, PostLogoutJwt, with Duende's refusals of a missing session id for a client that requires one, a token naming neither subject nor session, and a nonce. Ours, recorded: the method building the requests is not virtual and gives the session id only to a client with BackChannelLogoutSessionRequired (Duende's request carries it to every client and its token writes sid whenever present), so no step can hand it on; clients are told concurrently and each on its own, a failure raising BackChannelLogoutFailureEvent; the lifetime is LogoutTokenLifetime, not a fixed 300 0.177.0, Ours (security): DefaultSignOutService notifies the ending session's clients when the logout id it is given describes another subject or session, or names no clients; Duende notifies whoever the logout id names. The shipped logout page sets such a logout id aside for one of the session's own, so the front-channel iframes follow too. |
| Front-channel logout | ✅ | ✅ notified from the signed-out page — and actually loadable in a browser only since 0.106.0: until then the page's policy was default-src 'none' with no frame-src, so every iframe was blocked (§10, Authentication); no SignOutIFrameUrl endpoint (§1a). Reaching that page is the point: until 0.40.0 an RP-initiated logout carrying a registered post_logout_redirect_uri was answered with a 302 to the client, so the iframes had nowhere to render and no client was notified by any channel. 0.129.0 — and the SAML service providers, Duende 8.0.1's, measured (EndSessionRequestValidator, GetIdentityServerSignoutFrameCallbackUrlAsync, Saml2LogoutNotificationService, Saml2FrontChannelLogoutRequestBuilder, EndSessionCallbackHttpWriter): the logout id carries the session's SamlSpSessionData, and ISamlLogoutNotificationService (the SAML package's) adds a signed HTTP-Redirect LogoutRequest per provider — its NameID and SessionIndex — to FrontChannelLogoutUrls, with frame-src 'self' for the answers. Duende renders them in its callback page and only to providers with a Redirect endpoint; here they join the list a host's page already loads, to the one SingleLogoutServiceUrl (bindings: re-measure release 18). An unreachable provider raises SamlLogoutNotificationFailureEvent where Duende logs. LogoutRequest.ClientIds, SamlSessions and Parameters (the end_session request's own, less the four acted on; a dictionary, not Duende's NameValueCollection); LogoutRequest.UiLocales as of 0.156.0, for a request that stands, as Duende's LogoutMessage carries it |
| RP-initiated logout ending | ✅ signed-out page, with the return offered as a link | ✅ as of 0.40.0, and the same shape. A verified client is sent to the signed-out page rather than straight back to itself, and its registered post_logout_redirect_uri is rendered there as a link with state appended — RP-Initiated Logout 1.0 §2's second permitted ending, and the only one that leaves room for the notifications. This is what gave ILogoutContextFactory's postLogoutRedirectUri parameter a producer: it had been carried into the stored message, read back into LogoutRequest.PostLogoutRedirectUri and rendered as an anchor on a shipped page since 0.12.0, with nothing anywhere writing it — §1e's never-written shape, in a chain ending at markup a user was meant to see. No AutomaticRedirectAfterSignOut equivalent yet: the interactive pages run no script, so an automatic return would be a meta refresh racing the iframes it is supposed to let finish |
| Federated sign-out | ✅ Hosting.FederatedSignOut |
✅ outbound 0.42.0, inbound 0.43.0, and both actually exercised from 0.44.0. The gap always had two halves and only one is what Duende's type does. Outbound — fixed. A session established through an upstream provider used to be ended here and left live there: ExternalLogin writes idp onto the local principal at sign-in and no sign-out path read it, so the user signed out, clicked sign in, and was returned silently by an upstream session they were never asked about. The machinery existed on both sides and was unreachable — AddExternalOpenIdConnect sets OpenIdConnectOptions.SignOutScheme on every registration, SamlSpHandler has a complete outbound SignOutAsync — and nothing called either: §1e's read-but-unreachable shape, in configuration that reads as a capability. ISignOutService now reports the scheme still to be reached and the two callers that own a browser act on it, last, after everything that is this server's own business. A provider whose handler cannot sign out is not asked to, and a stale idp naming a removed provider lands in the same place. The SAML single-logout endpoint deliberately does not propagate — that sign-out arrived from a federation partner and owes it a LogoutResponse, so an upstream session survives a sign-out that came in that way. Inbound ✅ as of 0.43.0, and it is Duende's FederatedSignOut proper: an upstream IdP notifying this server at a handler's remote sign-out path used to clear the cookie named by SignOutScheme directly, bypassing ISignOutService, so this server's own relying parties were told nothing — the 0.40.0/0.41.0 defect a third time, in a path that is not ours to call. FederatedSignOutHandlerProvider hands out the cookie handler wrapped and observes its sign-out, which is what makes the detection exact: a wrapper around the request cannot tell afterwards whether the session ended, because the cookie handler caches its authenticate result and still reports the ticket it has just deleted. The value is that it is structural — three consecutive releases found a path that ended a session and told nobody, and anything that drops this cookie now runs the sequence whether or not whoever wrote it knew the sequence existed. Front-channel iframes are not rendered on this path: the response belongs to the upstream's handler, and the notification arrives in an iframe of the upstream's own page. 0.44.0 is the release that found out whether any of this worked. Until it, nothing in the repository called the Federation assembly's registration methods, so both halves had been driven only through handlers written for the tests — see §1e row 9. Through the real SAML SP handler the outbound half sent a LogoutRequest naming nobody (ExternalLogin built the local principal from sub, name and idp and dropped the provider subject that SamlSpHandler.SignOutAsync looks for), and a provider registered without IdpSingleLogoutUrl — nullable, no default, an allowed registration — turned a plain sign-out into a 500 that left the user signed in, because the response carrying the deleted cookie was replaced by the error response. The provider subject is now carried as ClaimTypes.NameIdentifier, where it reaches no issued token, and a missing SLO url is a warning naming the scheme. 0.52.0 found the same NameID naming nobody a third time, through a third door, and this one broke sign-in as well. SamlSpOptions.NameIdClaimType is public, documented, defaulted and unconstrained, so setting it is a registration a host is invited to make — and the handler honoured it by carrying the subject under that type instead of the canonical one. ExternalLogin looks for the provider subject under sub or ClaimTypes.NameIdentifier, found neither, and refused the sign-in outright; had it succeeded, SignOutAsync reads the configured type off the local principal, which ExternalLogin builds with the canonical type, so the LogoutRequest would again have named nobody. An option that broke the feature it configured at every value but its default — not §1e's never-written shape but its neighbour: a member written to a value that cannot work, read faithfully, and honoured into a failure. Fixed by deciding where the boundary is rather than patching either end: ClaimTypes.NameIdentifier is the contract the handler owes unconditionally, because it is what every consumer of an external identity in this tree reads, and NameIdClaimType is an additional mapping for a host whose own external-login handling reads something else. Nobody who set it loses the claim they asked for. Red-first, both halves asserted, because the sign-in half fails first and would have hidden the other. #40 items 14 and 15 0.179.0 — an OpenID Connect provider with no end_session_endpoint (Google publishes none) is skipped with a warning and the browser goes on to the signed-out page; until then ASP.NET Core's handler threw, the sign-out answered 500 after the relying parties had been told, and the session cookie survived. Duende's template fails the same way; measured against OpenID Connect Discovery, which makes the endpoint optional. The upstream end-session request still carries no id_token_hint — Duende's template keeps the provider's id_token for it, the shipped page does not (the gateway guide shows the host's way) — and, first in 0.179.0, an OpenID Connect upstream is driven end to end in-process (OidcFederationHosts) |
6. Security features
| Feature | Duende | Here |
|---|---|---|
| DPoP (RFC 9449) sender-constrained tokens | ✅ IDPoPProofValidator, DPoPOptions, IReplayCache |
✅ as of 0.17.0 — IDPoPProofValidator, DPoPOptions, IReplayCache, and DPoPConfirmation binding cnf.jkt. Before it, mTLS was the only way to sender-constrain a token here, and it is the one a browser app cannot use: an SPA had no option but a bearer token. The token endpoint validates the proof, binds the access token, and answers token_type: DPoP; Client.RequireDPoP makes it mandatory per client; refresh tokens issued under it are bound too (§5), and dpop_jkt binds the code (§10). Corrected in 0.77.1: both of those bindings were lost on the way into storage until then — CodeDto and RefreshTokenDto had no member for the thumbprint, so the grant came back unbound and the token endpoint's correct check compared a proof against null. No test stored a bound grant and redeemed it; GrantRoundTripTests now round-trips every member of both by reflection, and DPoPGrantBindingTests redeems each binding through the real endpoints. Server-provided nonces (§8) as of 0.85.0, by Client.DPoPValidationMode (Iat, Nonce, IatAndNonce, Custom, Duende's values) — until then recorded here as "deliberately absent", which the 1.0 rule does not allow for anything off the non-goal list. Stateless, as Duende's are: a Data Protection-wrapped timestamp under its purpose string, accepted for ProofTokenValidityDuration plus DPoPOptions.ServerClockSkew, a missing, foreign or stale one answered use_dpop_nonce with a DPoP-Nonce header. The token endpoint now checks the proof before the grant, because grant validation spends a code and the nonce round trip's first leg would otherwise spend the code its retry needs. At this server's own resource endpoints since 0.86.0: the one sender-constraint validator behind /connect/userinfo and the local API looks up the token's client and holds the proof to its mode and skew, as Duende's LocalApiAuthenticationHandler does, and a nonce-mode client is challenged DPoP error="use_dpop_nonce" with a DPoP-Nonce header (RFC 9449 §9) — measured from that handler, which writes the validator's error and nonce into its challenge. Duende's userinfo endpoint reads no proof at all; this one has since 0.45.0, so the client's rules reach it too. Refusals carry Duende's code since 0.88.0: its DefaultDPoPProofValidator ends every failure it has not already coded as invalid_dpop_proof — a missing proof, a key other than cnf.jkt, a missing ath — and its local API handler challenges with that code; until then every refusal here but the nonce said invalid_token, which stays the answer for a bound token sent as Bearer. A token under the DPoP scheme must carry cnf.jkt since 0.89.0, measured from Duende's ValidateHeaderAsync, which its local API handler runs against the token's claims: no cnf is "Missing 'cnf' value.", one that does not parse or has no jkt is "Invalid 'cnf' value.", both invalid_dpop_proof. An unbound token there is refused without a sender-constraint event — nothing is bound, so nothing was violated — and a certificate-bound one is recorded as WrongScheme. The challenge carries an error_description since 0.90.0, in the shape measured from Duende's HandleChallengeAsync — DPoP error="…", error_description="…" — with Duende's own strings for the refusals made around the proof ("Missing DPoP proof value.", "Too many DPoP headers provided.", "Missing 'cnf' value.", "Invalid 'cnf' value.", "Must use DPoP when using an access token with a 'cnf' claim"), filtered to RFC 6750 §3's characters. The proof validator's descriptions are Duende's own since 0.91.0, measured from its DefaultDPoPProofValidator — "Missing DPoP proof value.", "Malformed DPoP token.", "Invalid 'typ'/'alg'/'jwk'/'htm'/'htu'/'ath'/'jti'/'iat'/'nonce' value.", "Missing 'iat'/'nonce' value.", "'jwk' value contains a private key.", "Invalid signature on DPoP token.", "Detected replay of DPoP proof token." — so they read the same at the token endpoint's error_description, through which Duende's token request validator passes them, and in the resource endpoints' challenge. One of the old ones repeated client input (the alg the proof named); none does now. A RequireDPoP client that sends no proof is invalid_request since 0.92.0, "Client requires DPoP and a DPoP header value was not provided.", as Duende's token request validator — the property's only reader — answers it, for every grant; it had been invalid_dpop_proof, and no test had sent such a client a request without a proof. At the pushed authorization endpoint since 0.87.0, measured from Duende's PushedAuthorizationEndpoint and PushedAuthorizationRequestValidator: more than one DPoP header is invalid_request; a proof longer than InputLengthRestrictions.DPoPProofToken is invalid_dpop_proof; the proof is validated for POST to the endpoint under the client's mode and skew, a nonce answered use_dpop_nonce with a DPoP-Nonce header; and it must match the request's dpop_jkt (invalid_request, Duende's description) or supplies it, binding the code to the key that pushed. The one remaining difference is cosmetic: Duende writes use_dpop_nonce with an empty error_description, and this endpoint omits the member. This row used to close "resource-server-side validation belongs with item 10", and that sentence was wrong in the way that matters: validation at your resource server is indeed not this server's business, but this server hosts resource endpoints of its own, and at those it is the resource server. A defect in shipped code was filed as a feature not yet built, and read that way for twenty-eight releases. Closed in 0.45.0 — see §1e row 10: /connect/userinfo now requires the DPoP scheme, a proof for this method and URL by the key cnf.jkt names, and an ath naming that token, which is the first time DPoPProofValidationContext.AccessToken has been set by anything but a unit test. The validator opened in 0.138.0, measured from Duende 8.0.1: DefaultDPoPProofValidator unsealed with Duende's protected virtual steps in Duende's order (header with the access token's cnf, signature, payload: ath, jti, htm, htu, iat, freshness, replay), the context's ValidateAccessToken/AccessTokenClaims, the result's JsonWebKey, Confirmation, Payload, AccessTokenHash, TokenId, IssuedAt; Proof/Thumbprint renamed ProofToken/JsonWebKeyThumbprint. htu compared as Duende does — scheme and host without case, port, path — where it was the whole string. Ours, recorded: the signature also held to SupportedSigningAlgorithms, the replay event, a filtered nonce catch. #74 |
InputLengthRestrictions |
✅ | ✅ as of 0.29.0, and it was not only an abuse control. Seventeen bounds as of 0.87.0, with DPoPProofToken (4000, read at the token and pushed authorization endpoints, invalid_dpop_proof at both, as Duende reads it) the latest, after RFC 8707's resource at 0.77.0 and CIBA's user_code before it (the row said eleven until 0.74.0, a count left behind by the bounds added since — 0.72.0's client_notification_token and 0.74.0's id_token_hint among them), all of them enforced, re-checked by grep for a reader of each — a limit nothing reads is a setting an operator can tune while the request it names goes on unbounded, so parameters still unbounded are absent from the group rather than present and ignored. It absorbed the binding-message cap 0.27.0 had written into the CIBA endpoint as a local constant, which is how a general control gets discovered one parameter at a time. The finding inside it: code_challenge had been bounded to 43–128 since the authorize validator was written and code_verifier never was — the same rule from the same section of RFC 7636, with the enforced half the one that cannot fail, because an S256 challenge is 43 characters whatever the verifier was. The unenforced half is where PKCE's protection lives: the attacker who intercepts a code holds the challenge too, so a short verifier is brute-forced offline and the code redeemed, and a public client has no secret behind it. A third instance of a rule applied to one of its two halves, after RFC 9207's iss on three response builders of four and §1e row 2's six consent members on one approval screen of two — and the first where the half chosen was the one that did not matter. #100 Eighteen as of 0.120.0, with Duende's DeviceCode (100), read at the token endpoint before the device store is asked. Nineteen as of 0.121.0, with Duende's LoginHintToken (4000), read at the backchannel endpoint; LoginHint and Scope are read there too now. Twenty as of 0.124.0, with Duende's Jwt (51200), bounding the handler the JWT request validator reads every request object with, as Duende's constructor bounds its. Twenty-eight as of 0.132.0 (re-measure release 6), every one Duende reads: ClientSecret with ClientId in the Basic and post-body secret parsers (and the Basic header against both, before decoding), GrantType, AuthorizationCode, RefreshToken and AuthenticationRequestId at the token endpoint, TokenHandle (with Jwt for a JWT) wherever an access token is validated, UiLocale and DPoPKeyThumbprint at authorize — 100 each, refused strictly over, measured. Not taken: IdentityProvider and ExternalError, which Duende declares and never reads. Our token-endpoint refusals name the parameter in error_description where Duende's carry none. Found with it: Duende answers invalid_token for every invalid access token; this server says invalid_request for one it cannot find or verify — recorded, not changed here. |
ValidationOptions |
✅ | ✅ as of 0.39.0 — InvalidRedirectUriPrefixes, and the row was worth more than the option. Registration answered whether this server would send a browser somewhere and nothing asked what it was sending it to, so a URI whose scheme executes was honoured everywhere a registered one is: the authorize endpoint's redirect_uri, the end-session post_logout_redirect_uri, the front-channel logout iframes, the consent screen's Client.ClientUri anchor, and both SAML endpoints. The SAML pair is the live one. The HTTP-POST binding answers a service provider by returning an auto-submitting HTML form whose action is the registered reply address; WebUtility.HtmlEncode stops markup escaping the attribute and leaves javascript: exactly as it was, the form submits on onload with no click, and this HTML is written by an endpoint rather than served as a Razor page — so the CSP that 0.30.0 put on the interactive pages is not over it. That is script running in this server's own origin, the origin holding the session cookie. The refusal therefore had to be made before the first form is built and delivered by the error page: the SSO endpoint's own refusal path posts a Responder/NoPassive status to the same reply address, so a guard placed one line later would have announced its refusal through the thing it was refusing. The two OIDC redirect legs are the same rule a layer weaker — a browser will not follow javascript: in a Location header — and the iframe and anchor were already behind sandbox="" and default-src 'none' respectively, which is why they are named here as second layers rather than as fixes. A deny list rather than an allow list of http/https, because a native client registers com.example.app:/callback and has every right to. Matching is against a normalised copy — tab, CR and LF stripped from anywhere, leading controls and spaces trimmed — because that is what a browser does before it decides what scheme it is looking at, and a StartsWith against the raw string lets java	script: walk past while the browser runs it. Client.LogoUri is deliberately not covered: it is rendered as <img src>, which does not execute javascript:, and data: there is a legitimate embedded logo the page's CSP already permits — the rule has a half it must not be applied to, and that is as much a part of counting the halves as finding them. Duende's exact default list could not be re-derived (docs.duendesoftware.com is unreachable from here), so the defaults are this server's reading of which schemes execute or carry their own origin rather than a copy of theirs |
| CSP options for interactive pages | ✅ CspOptions |
✅ as of 0.30.0 — CspOptions (Level, AddDeprecatedHeaders), and the row was worth more than the options group it names. There was no Content-Security-Policy and no X-Frame-Options anywhere in the tree, so every interactive page could be framed, the consent screen included — the one page where a single click grants an application access to a user's data, and whose precondition, a signed-in user, is exactly the precondition a framing attack needs. Antiforgery does not answer it: the browser submits the genuine form with the genuine token, because the user genuinely clicked the genuine button. A CSRF token proves a request came from this page, and in a clickjacking attack it did. AddIdentityServerUi() now registers the pages and their headers in one call, because a host that added the application part by hand got pages served correctly with nothing on them, which looks exactly like a page that is fine. /connect/checksession is deliberately still framable — relying parties embed it under Session Management 1.0 §4, and a blanket header would break that with no error anywhere, just relying parties that quietly stop noticing sign-outs — and still carries default-src 'none'; script-src 'sha256-…', the hash computed from the string actually returned rather than written down beside it — which is what keeps it correct now that 0.64.0 substitutes a configurable cookie name into that script. Level decides two real things, not a syntax flavour: frame-ancestors and hash sources are both CSP 2, so Level One rests the refusal on X-Frame-Options and the iframe on 'unsafe-inline'. #102 0.165.0 (breaking, plan row 41): CspLevel's numbers are Duende's, measured — One 0, Two 1 (1 and 2 until then); a level configured by name binds as before, one configured as a number by Duende's numbering. Ours, recorded: CspLevel stays in Fdyay.IdentityServer.Configuration, where Duende's is Duende.IdentityServer.Models. 0.177.0: form-action 'self' removed from the interactive pages' policy, as Duende's templates have none: the HTML Standard runs CSP's pre-navigation check at every redirect of a form submission's navigation, and Chrome and Safari enforce it, so the sign-in and consent posts were stopped at the redirect to the client's origin. |
| CORS policy service | ✅ | ✅ complete as of 0.49.0, and the caching row was the smaller half of what it found. InMemoryCorsPolicyService (client-registration origins) and DefaultCorsPolicyService (fixed origins), applied by an ICorsPolicyProvider over CorsOptions.CorsPaths; CachingCorsPolicyService + CorsPolicyCache in front of either, opt-in through AddCorsPolicyCache(). The finding is the fixed-origin service, which this row named for twenty-odd releases as though it worked. AllowedOrigins and AllowAll are properties on the instance, and the only registration for a custom policy service takes a type — so a host that chose it got a fresh, empty, transient instance per request and a service that refused every origin it had been configured to allow. Written at zero sites, read on every cross-origin request: §1e's never-written shape, in a class whose own doc comment offered it to "hosts that keep their browser clients' origins in configuration". AddDefaultCorsPolicyService(configure) is the registration that was missing, and the tests for it are container tests rather than unit tests for the reason the gap survived — constructed by hand the class always worked. On the cache, two departures from caching a boolean, both because the key is the Origin header and the header is chosen by whoever is calling. Only allowed origins are stored, so nobody can grow the cache with origins nobody registered and evict the entry that mattered; the key set stays inside what the host's configuration permits, and the lag falls on withdrawal rather than on grant — a newly registered origin works on its first request. And it stops at MaxEntries, pruning expired entries first, because a policy service may allow origins by pattern (every subdomain of a tenant) and then the allowed set is bounded by nothing; full, it asks every time, which is what this server does without a cache. Keys compare ordinally although both shipped services match ignoring case — a cache that answers differently from the service it caches is not a cache. What is cached is a permission: InMemoryCorsPolicyService excludes disabled clients precisely so a decommissioned client's script stops reaching the protocol endpoints, and with this in front of it, it stops up to one duration later. Hence a one-minute default, no duration that disables the cache (a host that does not want one does not register it), a public CorsPolicyCache.Clear() for a host that has just changed a registration, and a test asserting the withdrawal lands at the boundary and not before. Duende puts the duration in CachingOptions.CorsExpiration; here it is an argument to the registration, so §10's absent-groups list is unchanged — the ServerSideSessions reasoning, that a knob belongs where the capability is switched on rather than in a group that reads as one when nothing registered it. Duende's own default could not be re-derived (docs.duendesoftware.com is unreachable from here), so the minute is this server's reading of how long a withdrawn permission may stay in force, not a copy of theirs. |
| Signing algorithms accepted from a client | ✅ options SupportedRequestObjectSigningAlgorithms, SupportedClientAssertionSigningAlgorithms |
✅ as of 0.50.0, and the row is a fix rather than a knob. A client signs a JWT at two doors here — a request object (RFC 9101) and a private_key_jwt assertion (RFC 7523) — and neither validator set ValidAlgorithms, so the accepted algorithm was whatever the presented key happened to support: inferred from the registration, unstated, and unadvertised. Both lists default to the nine asymmetric algorithms that already worked, so introducing them refuses nothing that used to; both are published as request_object_signing_alg_values_supported and token_endpoint_auth_signing_alg_values_supported, which is the rule DPoPOptions.SupportedSigningAlgorithms already carried — a client is never told about an algorithm this server would then refuse. Symmetric algorithms are on neither list and are not meant to be: both credentials are verified against a key from the client's own registration. §10 |
| Access-token expiry means expiry | ✅ option JwtValidationClockSkew |
✅ as of 0.50.0, defaulting to zero. Nothing set ClockSkew, so IdentityModel's five-minute default applied and every JWT access token this server issued was honoured for five minutes past its published expires_in — at UserInfo, at introspection, at a local API. Not a member anyone could sweep for: it is a default accepted by not writing anything down, which is method note 22. The two places this server reads a JWT somebody else signed keep five minutes on purpose. §10 Measured 0.124.0, recorded for the whole-surface re-measure: Duende's default for JwtValidationClockSkew is five minutes, not zero, and Duende's JWT request validator reads it; here the request object's skew stays a fixed five minutes — Duende's default — so the two agree until a host changes the option. |
| Strict JAR / client-assertion audience validation | ✅ options StrictJarValidation, StrictClientAssertionAudienceValidation |
✅ — StrictJarValidation as of 0.124.0, and this row had it wrong until then. It said the option was absent because strictness was unconditional here, a request object's aud having to be this issuer. Measured 0.124.0, that is not what Duende's option governs: Duende's JWT request validator checks the audience always, as this server does, and StrictJarValidation (default off) has its two readers require what RFC 9101 §10 registers — a request object typed oauth-authz-req+jwt (JwtRequestValidator.ValidateJwtAsync, unless the context's own StrictJarValidation says otherwise; never for a signed CIBA request, whose validator passes false) and a fetched request_uri served as application/oauth-authz-req+jwt (DefaultJwtRequestUriHttpClient, ordinal). Both taken, tested at the non-default value. StrictClientAssertionAudienceValidation as of 0.176.0, and this row had it wrong until then. It said the option stayed absent because a client assertion's audience here had to be the URL it was sent to, unconditionally. Measured, it was not: an assertion was accepted when any of its audiences was this server's issuer or the URL it was sent to, and its typ was not read — so a multi-audience assertion, or one addressed to a URL two servers share, was accepted (the audience-injection shape of 2025). Duende 8.0.1's PrivateKeyJwtSecretValidator, measured and taken: off (the default), an assertion may name its issuer or an endpoint URL among its audiences (Duende's list: the issuer, and the token, CIBA and PAR endpoint URLs; ours, the issuer and the URL it was sent to); on, or for an assertion typed client-authentication+jwt (header compared ignoring case) whatever the option says, exactly one audience — a string or a one-element array — equal to the issuer but for one trailing slash, and ValidTypes = ["client-authentication+jwt"] (draft-ietf-oauth-rfc7523bis). Tested at both values. |
7. Stores
| Store | Duende | Here |
|---|---|---|
IClientStore |
✅ | ✅ — read through ValidatingClientStore<T> since 0.117.0, as Duende's AddClientStore<T> and AddInMemoryClients arrange (see the configuration validators row). As of 0.140.0, GetAllClientsAsync and the caching decorator (found 0.117.0), measured from Duende 8.0.1. GetAllClientsAsync is Duende's abstract IAsyncEnumerable<Client> member — breaking for a host's own store: the in-memory store yields in the order given, the validating store skips a refused client and logs and raises InvalidClientConfigurationEvent for it as a lookup does, the Dapper store reads the table by client id, the cache passes it through uncached. CachingClientStore<T> and AddClientStoreCache<T>: the host's store registered as itself, the validating store in front, the cache in front of that; a client held by id in the HybridCache keyed ConfigurationStoreCache (shared with the identity-provider cache) with an absolute expiration, Duende's 15 minutes by default; an unknown or refused client is not held (Duende's NotCachedException). Ours: the duration is an argument (AddClientStoreCache<T>(duration)) where Duende reads CachingOptions.ClientStoreExpiration, as recorded for Caching below; cache keys are this server's own; no store spans (StoreActivitySource), as no store here has one. Nothing in Duende's assembly enumerates clients but its decorators and ConnectedApplicationStore, which moved to release 18 with IConnectedApplicationStore. 0.175.0 — an empty default, Duende 8.0.1's EmptyClientStore, measured from its AddCoreServices: registered with TryAdd by AddIdentityServer(), answering no client, replaced by any store registered after it, so a SAML-only identity provider needs no AddInMemoryClients([]) — until then such a host refused to start in Development (ValidateOnBuild: no IClientStore) |
IResourceStore |
✅ | ✅ Resource.Enabled read where Duende reads it since 0.93.0, measured from its ResourceExtensions.FilterEnabled — identity resources, API resources and API scopes alike — applied by FindEnabledResourcesByScopeAsync in the resource validator and GetAllEnabledResourcesAsync in discovery, and from FindEnabledIdentityResourcesByScopeAsync in the userinfo generator. A disabled scope is an invalid scope, a disabled API is not in the audience, and neither is advertised. Before it, only the introspection secret check and the resource indicator lookups read the flag. As of 0.140.0, CachingResourceStore<T> and AddResourceStoreCache<T>, Duende 8.0.1's, measured: one entry, the store's GetAllResourcesAsync, held in the ConfigurationStoreCache HybridCache for Duende's 15 minutes (or the duration a host names), each lookup filtered from it — identity resources and API scopes by name, API resources by name or by any of their scopes; a null catalogue is not held and reads as empty Resources. 0.175.0 — an empty default, Duende 8.0.1's EmptyResourceStore (no resources; all of them empty with OfflineAccess true), registered beside the empty client store and for the same host; AddInMemoryIdentityResources and its siblings still compose one in-memory store over it |
IPersistedGrantStore |
✅ | ✅, and as of 0.64.0 whatever is registered is wrapped by DataProtectingPersistedGrantStore, which protects the Data column with the host's data protection ring (§10, PersistentGrants.DataProtectData). The wrapper is re-applied at every point a store is selected — the default, AddPersistedGrantStore<T>() and AddDapperStores — because the last two register over what AddIdentityServer() left; a host that calls services.Replace by hand afterwards is the one case it does not reach. Only Data is protected: Key is what a lookup is by, and the columns PersistedGrantFilter queries on have to stay readable, so this hides the contents of a grant and not the fact that a subject has one. As of 0.131.0 a filter must name something: Duende's PersistedGrantFilter.Validate() ("No filter values set."), called by the in-memory and Dapper stores — the Dapper store built no WHERE clause from an empty filter and so deleted every grant. Duende's filter also has ClientIds and Types; ours does not yet 0.176.0 — Ours (security): TryRemoveAsync and TryConsumeAsync, default-implemented members that take a one-time grant in one step and say whether this call took it — the in-memory store with ConcurrentDictionary.TryRemove/TryUpdate, the Dapper store with DELETE … / UPDATE … SET ConsumedTime … WHERE … AND ConsumedTime IS NULL counting rows (no schema change), the data-protecting wrapper passing both through. The authorization code store and the refresh token store take through them, so two requests presenting one code or one OneTimeOnly refresh token at once are answered once. Duende's store has neither and its callers read, then remove or consume. A store of a host's own keeps compiling: the default bodies read and then write, as before — and as open to the race. |
IPushedAuthorizationRequestStore |
✅ | ✅ 0.177.0: the Dapper store protects Parameters through IGrantDataProtector, as Duende's PushedAuthorizationSerializer protects them (purpose PAR, always; here under DataProtectData); a row written before still reads. |
| Signing keys | ✅ ISigningCredentialStore + IValidationKeysStore |
✅ both as of 0.15.0, alongside ISigningKeyStore — which is not a third of the same kind: it persists the keys automatic management generates, so it is part of the mechanism the other two replace. AddSigningCredential(SigningCredentials / SecurityKey+alg / X509Certificate2) registers both halves; AddValidationKey registers the published half alone, which is what a manual rotation needs so tokens signed by the outgoing key keep validating. RSA or EC as of 0.66.0, and the four releases it spent recorded as blocked are the point. This row used to read "RSA only, refused by name otherwise: the JWKS writer emits n and e and nothing emits crv/x/y, so an EC key would be published as an entry with two null fields" — every word true, and a description of a function nobody had written rather than a wall. 0.15.0 added that refusal correctly, because publishing a half-filled JWK is worse than refusing; what happened afterwards is that the reason to refuse was read as the reason it could not be done. Note 36's shape one release after note 36. The asymmetry that should have been noticed sooner: SupportedRequestObjectSigningAlgorithms and SupportedClientAssertionSigningAlgorithms have listed ES256/ES384/ES512 since 0.50.0, so this server verified EC signatures a client sent it and could not make one of its own — 0.62.0's SAML finding in the other protocol. Reading the path found one choke point and everything else already algorithm-agnostic: token creation asks IKeyMaterialService for credentials and hands them to the JWT handler, which signs ECDsa natively, and discovery derives id_token_signing_alg_values_supported from the validation keys' own algorithms, so an EC key advertises itself with no list to update. Two decisions inside the fix are worth more than the branch: the curve comes from the key's size and never from the length of the coordinates it exported, because deriving it from their length would make the padding verify itself — and x/y are left-padded here to the curve's field size rather than trusted to the platform, since a coordinate beginning with a zero byte and published one byte short is a key that verifies nothing while the JWKS looks well-formed (P-521 is why the size rounds up: 521 bits is 66 bytes); and the algorithm is checked against the curve rather than carried beside it, because RFC 7518 §3.4 pairs each ES algorithm with exactly one curve, so a mismatch is an unusable key rather than a weaker signature. And the option next door could not be honoured: KeyManagement.SigningAlgorithm defaults to RS256 and its generator is RsaKeyContainer, so an EC value produced an RSA key labelled ES256 — a JWKS whose alg contradicted its own kty, discovery advertising an algorithm this server could not sign with, and a credential the handler refuses. 0.52.0's shape, sitting unnoticed beside the row explaining why EC was impossible; it is refused at boot now, both RSA families accepted rather than only the default because a generated RSA key signs PS256 as happily as RS256. Automatic EC generation stays absent with its reason rather than half-built: the container, its serialisation, the protector and the rotation are RSA by construction, RsaKeySize sits beside the option to say so, and an EC key arrives from an HSM or a certificate authority through AddSigningCredential (§11, EC algorithms). 0.136.0 — instances on one store converge, Duende 8.0.1's KeyManager, measured: the oldest current key signs (MinBy(Created); the newest did), a key dated ahead clamps to age zero rather than being skipped, a key created while every key is within InitializationDuration (5m) is followed by InitializationSynchronizationDelay (5s) and a re-read of the store, the set is cached for InitializationKeyCacheDuration (1m) meanwhile, DeleteRetiredKeys gates deletion, and the options are validated with Duende's messages as the manager is built (InvalidOperationException where Duende throws Exception), KeyCacheDuration held to half of PropagationTime. Ours: the in-memory store is not waited on — no other instance can share it, and Duende has none. 0.137.0 — a key per algorithm, certificates, and keys on disk, measured: SigningAlgorithms (SigningAlgorithmOptions with UseX509Certificate) replaces SigningAlgorithm; a current key per algorithm — RSA, EC on its curve (ES256 P-256, ES384 P-384, ES512 P-521), or RSA in a self-signed certificate (CN= the issuer, SHA-256/PKCS#1, digital signature, server authentication, valid for the retirement age) — behind a KeyContainer base; signing by the first allowed algorithm, else the first configured; a plain key ineligible where a certificate is asked for, and keys of an algorithm no longer configured dropped; Validate with Duende's messages, at boot too, replacing the RSA-only guard; x5c/x5t for every X509SecurityKey in the JWKS; IKeyManager.GetCurrentKeysAsync; FileSystemKeyStore the default at KeyPath (by decision, as Duende's); DataProtectKeys. Ours: a static credential still does not sign while automatic management is on (Duende's signs first); the stored form is this server's own (version 2, the key's kind inside the protected data, so no Dapper column; version 1 read) and not interchangeable with Duende's files; the certificate subject is IssuerUri, as key management sits beneath the request; a key is made only for the algorithm that needs one 0.162.0: IKeyManager.GetCurrentKeysAsync(CancellationToken), Duende's arity, as an overload and a default interface method answering with GetCurrentKeysAsync(); KeyManager's passes the token to the store's loads, writes and deletes and the initialisation wait, as Duende's, and the key material service forwards its caller's token. GetAllKeysAsync keeps one arity |
IDeviceFlowStore |
✅ | ✅ in-memory and Dapper — and, as of 0.131.0, behind Duende's IDeviceFlowCodeService, measured: DefaultDeviceFlowCodeService makes the device code and hashes it and the user code before the store sees either, and every caller goes through it. Until then the store held both as issued. The hash is this server's handle hash (SHA-256, base64url) where Duende's is SHA-256 in standard Base64; the device code is this server's 32-byte handle where Duende's is 32 bytes in hex. In-flight authorizations do not survive the upgrade 0.177.0: the Dapper store protects Data through IGrantDataProtector, as Duende's operational store serializes it; a row written before still reads. |
IBackChannelAuthenticationRequestStore |
✅ | ✅ in-memory (0.27.0) and Dapper (0.67.0). Two single-record keys like the device store, and for the same reason: the client polls with the auth_req_id it was issued and the user answers a request they reached from a list, so one record has to be found from either. It carried the caveat server-side sessions carried before 0.13.0 — pending requests did not survive a restart and were not shared between instances, so a client could poll one instance while the user answered on another. 0.67.0 closes that half of #96: DapperBackChannelAuthenticationRequestStore, both dialects, a DapperStores.BackChannelAuthentication flag in All, and the sweep as a fourth branch of TokenCleanup rather than a new hosted service — the table it prunes sits beside three the same class already prunes on the same interval, and ServerSideSessionCleanup was the wrong model to copy for it. And one thing the device store is never asked, which is what makes this more than a copy of it: a surface that shows somebody what is waiting for them asks by subject, which is a query rather than a lookup — which is why SubjectId and IsComplete are columns instead of living only inside the serialised payload, since answering that by deserialising every row works at the scale of a test and not of a deployment. Both halves of "pending" belong to the query rather than the caller: a request the subject has already answered is not waiting on them, and one past its expiry cannot be answered at all, so leaving either out would have an approval surface offering somebody a decision they had already made or a button that does nothing. The sweep removes expired requests and never merely completed ones — a completed request whose client has not polled yet is still owed its answer, and deleting it would turn a successful approval into a client waiting out the lifetime for a request that no longer exists. What remains of #96 is protocol surface rather than persistence — ping delivery landed in 0.72.0 and push in 0.73.0, id_token_hint in 0.74.0, user_code with its discovery flag in 0.75.0, and signed request objects in 0.76.0 0.177.0 (security): the Dapper store keys a request by sha256: + the Base64Url SHA-256 of its auth_req_id and protects Data (the id, the notification token, the approving user's claims) through IGrantDataProtector, as Duende's DefaultBackChannelAuthenticationRequestStore hashes its key and serializes through IPersistentGrantSerializer (measured); a row written before is read by its unhashed key, which is never tried for a value shaped like a hashed key. |
IServerSideSessionStore |
✅ | ✅ in-memory (0.12.0) and Dapper (0.13.0). The in-memory one is development-only and says so: sessions held per-process vanish on deploy and are not shared between instances, so a multi-instance host on it sees users appear signed out whenever the balancer moves them. DapperStores.ServerSideSessions is in All, and Dapper Replaces where AddServerSideSessions() TryAdds, so the database store wins whichever order a host makes the two calls in 0.175.0 — the server's clock: both stores sweep against the registered TimeProvider (new constructors, the old ones kept on the system clock), as Duende 8.0.1's InMemoryServerSideSessionStore(TimeProvider) does; until then both read DateTime.UtcNow. Not changed: Duende's in-memory sweep takes Expires < now, these take <= |
IUserConsentStore |
✅ | ✅ |
IConsentMessageStore |
✅ | ✅ as of 0.161.0 — 🟡 until then: IMessageStore<T> covers it generically. ProtectedDataMessageStore<T> as of 0.150.0 (re-measure release 22, from 16), Duende's default for its message stores: the message serialized, protected and Base64Url-encoded as its own id, so a farm sharing the key ring needs no shared table. Ours: opt-in (AddProtectedDataMessageStores(), for the error and consent messages) because the error page shows the id; the expiry carried in the message; a write failure throws where Duende's returns no id. 0.161.0 took Duende's name, measured from 8.0.1: IConsentMessageStore (WriteAsync(id, Message<ConsentResponse>, ct), ReadAsync, DeleteAsync), Message<TModel> (Created in UTC ticks, Data; Duende's data-only constructor is internal), and the default registered with TryAddTransient as Duende's AddPluggableServices does. GrantConsentAsync and DenyAuthorizationAsync write through it, stamped with the server's clock, and the authorize endpoint reads then deletes, answering a message with no data server_error ("consent message is missing data", measured). The default sits over IMessageStore<ConsentMessage> and the consent cookie, where Duende's keeps a cookie per id, so AddProtectedDataMessageStores() and a host's own message store keep carrying decisions, and one in flight across the upgrade is still read; the interaction service and the endpoint take the store through new constructor overloads. Ours: the id names the client and the subject, as this server's binding always has, where Duende's ConsentRequest.Id hashes them with the request's nonce and scopes — not taken, since it would narrow which request a decision answers; so the endpoint reads and deletes on every signed-in request rather than on the callback alone, deleting as it reads; and a refusal passed to GrantConsentAsync is still not handed on (Duende's hands on any response) 0.177.0 (security), Ours: InMemoryMessageStore<T>, the default here for the error and consent messages (Duende's default is ProtectedDataMessageStore<T>, which keeps nothing server-side, measured from AddPluggableServices), holds DefaultCapacity (10,000) messages at most, the oldest dropped, and sweeps expired ones oldest-first in constant work per write — until then a write walked every message held; the authorize endpoint stores a client_id/redirect_uri only within InputLengths, and ErrorMessage keeps neither past MaxValueLength (2,048), where a refused anonymous request used to store megabytes. |
IIdentityProviderStore (dynamic providers) |
✅ | ✅ in-memory since 0.47.0 — this row said ⬜ for 55 releases after it shipped, a row nothing re-read — and as of 0.102.0 Duende's validating and caching decorators in front of it (§9, Dynamic identity providers). No Dapper store: a host writes its own, which is what AddIdentityProviderStoreCache<T> is for |
| SAML stores | ✅ 4 + 2 decorators | ✅ 3 stores and the validating decorator as of 0.71.0; the caching one declined on the record — ISamlServiceProviderStore, ISamlSigninStateStore (0.22.0) and ISamlLogoutSessionStore (0.61.0). The logout-session store was not a parity row either: single logout is a sequence whose every hop goes through the browser, so without somewhere to keep who asked and who is left, the endpoint could only end the session and answer the requester — the other service providers holding a session from the same sign-on were never told anything. The sign-in state store was never only a parity row: without it the single sign-on endpoint rebuilt a POST-bound AuthnRequest as an unsigned redirect query to put it in the return URL, so a service provider registered with a request signing certificate that posted its request was refused on the return leg for a signature it had sent. Compared in §11 0.117.0: the validating decorator asks ISamlServiceProviderConfigurationValidator and raises InvalidSamlServiceProviderConfigurationEvent (see the configuration validators row). 0.143.0: GetAllSamlServiceProvidersAsync on the interface (breaking for a host's store) — the in-memory store yields every provider as given, a disabled one included, and answers a disabled one as none on lookup (Duende's, with its duplicate-entity-id message); the validating store yields each it would serve, raising the event for the rest, and answers a disabled provider as none on lookup so a host's store need not filter (ours; Duende asks Enabled at each reader). IConnectedApplicationStore (Duende's): the core ConnectedApplicationStore answers clients, AddSaml swaps in SamlConnectedApplicationStore — clients, then providers, the client first on a shared id — unless the host registered its own. |
| EF Core provider | ✅ | — Dapper by choice |
8. Services and extensibility
| Service | Duende | Here |
|---|---|---|
IProfileService |
✅ | ✅ — and as of 0.126.0, what it is told: Duende 8.0.1's ProfileDataRequestContext.ProtocolRequest and RequestedResources, measured from its DefaultClaimsService and UserInfoResponseGenerator (IL decoded). For each token a grant issues the profile service is handed the validated token request as an IValidatedRequest and the ResourceValidationResult the token is cut for (the access token's narrowed by resource indicator); at userinfo the enabled identity resources the token reaches, with no protocol request, as in Duende. Carried through Duende's TokenCreationRequest.ValidatedRequest and ValidatedResources. The context gained Duende's empty constructor and settable properties, and its constructors refuse a null argument by name, as Duende's do (breaking for a caller that passed null; nothing here did). ProfileDataRequestContext.SamlAttributes, and the SAML half of ProtocolRequest.Saml2SSoResponseGenerator.CreateAttributesAsync: both SAML sign-on doors ask it, as "Saml2SsoResponseGenerator" (Duende's literal; ProfileDataCallers.Saml2SsoResponseGenerator here, Duende declaring no constant), with the sign-on as a ValidatedAuthnRequest and the claim types the provider's AllowedScopes resolve to; RequestedResources is left unset, as Duende leaves it. SamlAttributes are issued as they are, ahead of the mapped claims — see §11's attribute release row. 0.127.0 — what it is asked for, and what it may give, measured from Duende 8.0.1's DefaultClaimsService (FilterProtocolClaims, FilterRequestedClaimTypes, Constants.Filters.ClaimsServiceFilterClaimTypes) and GetAccessTokenClaimsAsync: the nineteen protocol types are neither requested of it nor accepted from it, for both tokens — until 0.127.0 a profile service's scope claim widened the token and its cnf or sid rebound it; an access token asks only for its API resources' and API scopes' user claims (until 0.127.0 the identity resources' too, so a user's profile reached every API's token — breaking for a host that relied on it); and it is asked even with nothing to request. Found measuring it: DefaultProfileService and TestUserProfileService issued every claim for an empty request, against Duende's (both through AddRequestedClaims) and against this server's own documented rule; now nothing, as Duende's. The overridable FilterProtocolClaims/FilterRequestedClaimTypes steps wait for the open-defaults release (re-measure release 14), the claims service being sealed here. 0.128.0 — active users only, measured from Duende 8.0.1's AuthorizeInteractionResponseGenerator.ProcessLoginAsync, TokenValidator.ValidateAccessTokenAsync and Saml2SsoInteractionResponseGenerator: IsActiveAsync is now asked at authorize (AuthorizeEndpoint — inactive is signed out: sign-in, or login_required under prompt=none), for every access token with a sub whose client is found (AccessTokenValidation, with the token's claims as the user — inactive is invalid_token, so introspection, userinfo, the local API and a token-exchange subject token all refuse it) and at SAML sign-on (SamlSsoEndpoint — sign-in, or NoPassive). Stricter than Duende: IdP-initiated SAML asks too; Duende's checks only that the user is signed in. Not taken: IdentityTokenValidation, which Duende declares and never asks with — the re-measure's "four callers" and "identity-token hints" were an over-count, corrected there. Found doing it: the discovery generator was a singleton holding the extension grant validators (Duende registers it transient), which a scoped profile service — ASP.NET Identity's — could no longer sit beneath; now transient. |
IClaimsService |
✅ | ✅ — as of 0.147.0 the default is open (re-measure release 14, decision 5), Duende 8.0.1's DefaultClaimsService steps, measured: GetStandardSubjectClaims, GetOptionalClaims, FilterRequestedClaimTypes, FilterProtocolClaims, protected and virtual, in Duende's order; Profile. Ours, recorded: the two methods that run them are not virtual, and the nineteen protocol claim types are held back again after either filter step, so an override filters more, never less (0.127.0's guardrail; tested against an override of each). The interface keeps this server's TokenCreationRequest shape. Found doing it: only the first amr was copied; every one is now, as Duende's 0.173.0, Duende's, measured: GetStandardSubjectClaims writes auth_time as XMLSchema#integer64 whatever the subject's claim says (Duende's re-creates it from GetAuthenticationTimeEpoch), so a sign-in through IdentityServerUser, whose auth_time is typed string in both servers, reaches the token as a JSON number; it was copied with its own type. Ours: an auth_time that is not an integer is copied as it is and the token issued, where Duende's long.Parse throws and fails the request. |
ITokenService / ITokenCreationService |
✅ | ✅ — as of 0.147.0 DefaultTokenCreationService is open (release 14, decision 5), Duende 8.0.1's steps, measured: CreatePayloadAsync, CreateHeaderElementsAsync, CreateJwtAsync — the one a host overrides to sign with an HSM or a cloud key service — in Duende's order; Keys, TimeProvider, Options. Ours, recorded: CreateTokenAsync is not virtual and reads the JWT back before it leaves — signed with one of the nine RSA/EC algorithms (never none or a shared secret), with one Token.AllowedSigningAlgorithms allows when it names any, a logout token typed logout+jwt — so an override of a step cannot issue a token this server would refuse to; aud stays an array for one audience (Duende's is a string) 0.173.0, Duende's, measured from TokenExtensions.CreateJwtPayloadDictionary: amr is written as an array of its distinct values with one value as with several (it collapsed to a string for one), and auth_time as a JSON number (OpenID Connect Core 1.0 §2; decided 2026-11-03, breaking before 1.0). Ours: auth_time is read by name, a number whenever it is an integer whatever its value type — Duende's AddObject goes by value type only, which is all it needs because its claims service always types it — so the kept access token a refresh hands back from a grant stored before 0.173.0 is written with a number too; one that is not an integer is written as text, never failing the token. |
IRefreshTokenService |
✅ | ✅ — as of 0.147.0, AcceptConsumedTokenAsync (decision 8), Duende 8.0.1's hook, measured: asked when a consumed token is presented again, after the client check, false by default. Ours, recorded: a token the hook refuses revokes its lineage and raises RefreshTokenReplayDetectedEvent, where Duende's only refuses — an override can accept, never refuse without revoking; overriding it is documented as weakening replay detection (RFC 9700 §4.14). The class is unsealed (RefreshTokenStore, TimeProvider); its public methods are not virtual, and the interface keeps this server's RotateAsync shape And PersistentGrantOptions.DeleteOneTimeOnlyRefreshTokensOnUse as of 0.150.0 (re-measure release 22), Duende's: a OneTimeOnly token deleted on use rather than kept as consumed. Ours: off by default, Duende's on — a deleted token presented again is only unknown, so the lineage revocation on replay (RFC 9700 §4.14.2) needs the consumed row. And as of 0.167.0, PersistentGrantOptions.ConsumedRefreshTokenGracePeriod (approved 2026-10-15, default zero): the default AcceptConsumedTokenAsync accepts a consumed token iff now − ConsumedTime ≤ grace (the TimeProvider), every other check unchanged, for a BFF racing itself. Measured and taken: Duende's UpdateRefreshTokenAsync stamps ConsumedTime only when it has none and always stores a new handle, so the window runs from the first consumption and an accepted reuse forks the lineage — ours re-stamped it on every consumption until 0.167.0. Ours, recorded: the option itself (Duende's has none: a host overrides the hook, which still replaces it here); an information log on an accepted reuse (Duende's logs nothing there) and Duende's warning "Rejecting refresh token because it has been consumed already." on a refused one; a negative value refused by UseIdentityServer; while it is on, revoking a refresh token revokes its lineage (Duende's TokenRevocationResponseGenerator removes the one handle, which would leave the token it was rotated from acceptable). After the window, reuse is a replay as before — refused, the lineage and its forks revoked. A third constructor takes the logger; the earlier two are kept 0.174.0 — a reused sliding token slides, measured from Duende 8.0.1's UpdateRefreshTokenAsync: for Sliding, whatever RefreshTokenUsage, the lifetime becomes the seconds since creation plus SlidingRefreshTokenLifetime, capped at AbsoluteRefreshTokenLifetime only when that is above zero, and a ReUse token is written back under its handle, DefaultRefreshTokenStore.UpdateRefreshTokenAsync writing the expiration as creation plus lifetime; CreateRefreshTokenAsync caps the first lifetime the same way. Here only RotateAsync's OneTimeOnly path slid, the in-place write kept the row's expiry, and an absolute lifetime of zero capped a sliding token at its creation — issued expired. All three fixed; the consumed marker and the grace period are untouched. Who is affected: a ReUse + Sliding client, whose session in use no longer ends at its first lifetime, and a Sliding client with AbsoluteRefreshTokenLifetime = 0, whose refresh tokens now work. A host's own IRefreshTokenStore that keeps the expiry in UpdateRefreshTokenAsync keeps the old behaviour. 0.176.0 — Ours (security): TryRedeemAsync, default-implemented (answers true, takes nothing): the token endpoint asks it once every check on a refresh request has passed — the DPoP binding and the host's ICustomTokenRequestValidator included — and before any token is issued. The default service consumes a OneTimeOnly token in one step (IRefreshTokenStore.TryConsumeRefreshTokenAsync, or TryRemoveRefreshTokenAsync with DeleteOneTimeOnlyRefreshTokensOnUse); a request that loses is met as a presentation just after the winner's — accepted within the grace period, otherwise invalid_grant, "Invalid refresh token.", its lineage revoked and RefreshTokenReplayDetectedEvent raised (a deleted token: refused, nothing more). RotateAsync then finds it consumed. TokenEndpoint gains a constructor taking the service; the one before it is kept and takes nothing. Duende reads in the validator and consumes in the response generator, two steps. |
IKeyMaterialService |
✅ | ✅ |
IIssuerNameService |
✅ | ✅ as of 0.25.0, with the async half declined and recorded. The parameterless half was the real gap and it was closed: GetIssuer() answered only from configuration, so its one caller had to branch on whether there was a request and pick a different member for each side, and IIdentityServerTools was about to be the second to do that. It now answers from whatever context there is — configured issuer, else the request in flight, else null — and the request case delegates to the overload that takes a context rather than reimplementing it, so a derived issuer cannot come out of one member spelled differently from the other. That is 0.18.0's defect prevented by construction rather than by care. Async is declined: Duende's is async because its implementation may reach a store, and this one reads options and a request. A signature that awaits nothing, matched for the shape of the signature, is the same argument that declined IUserCodeGenerator in 0.23.0 — if a host's replacement ever needs to await, that is the release that makes it async. #91 |
IIdentityServerInteractionService |
✅ 12 members | ✅ 12 members (#38); DenyAuthenticationAsync denies a SAML sign-in as of 0.151.0, through ISamlSignInDenialService (the SAML package's, registered by AddSaml) |
IConsentService |
✅ | ✅ as of 0.20.0, and the third slice of item 6 in a row to arrive with a defect rather than as a bare seam. The policy had two halves in two assemblies — the authorize endpoint decided whether consent was needed, the interaction service recorded the answer — and they disagreed about Client.AllowRememberConsent: the recording half treated it as a veto over the user's choice, the deciding half never read it. A client registered never to have its decisions remembered had them remembered for AuthorizationCodeLifetime, five minutes by default, and so did any user who unticked the box. The short-lived row was load-bearing until 0.16.0 put the decision on the consent message instead; after that it was read on no path except the one the setting forbids. An unremembered decision now stores nothing and withdraws what an earlier one stored. Client.ConsentLifetime arrives with the service that reads it. Duende's signature takes a ClaimsPrincipal and ParsedScopeValues; this takes the subject id and plain scope values, because there are no parameterized scopes here and the subject id is the only thing either half reads. #80 0.116.0: parameterized scopes exist, so RequiresConsentAsync takes Duende's ParsedScopeValues, and Duende's order is taken whole — no scopes needs no consent; a parameterized scope or offline_access needs it whatever was remembered (see the parameterized scopes row). |
IEventService / IEventSink + 28 event types |
✅ | ✅ as of 0.65.0: 23 of Duende's 28 by concept, 34 raised in all, and every one has a raise site — and as of 0.102.0, 24 of 29 and 35. Duende 8.0.1 has 29 event types, measured as direct subclasses of its Event in 0.102.0; the 28 counted in 0.65.0 are the other 28, and the one missed is InvalidIdentityProviderConfiguration, the only one whose name does not end in Event. It is here as InvalidIdentityProviderConfigurationEvent (Duende's name, category, type and message; id ErrorStart + 2, Duende's offset in this server's error block), raised by ValidatingIdentityProviderStore<T>. IEventService/IEventSink with DefaultEventService, DefaultEventSink and HttpEventService; the Event base with EventTypes, EventCategories and EventIds; and EventsOptions, four flags all off by default because events carry client ids, subject ids and addresses and where those end up is the host's decision. Raised: ClientAuthenticationSuccessEvent/…FailureEvent (ClientSecretValidator), TokenIssuedSuccessEvent/…FailureEvent (TokenEndpoint), TokenRevokedSuccessEvent (RevocationEndpoint), RefreshTokenReplayDetectedEvent (DefaultRefreshTokenService), as of 0.35.0 the three that describe a person rather than a client: UserLoginSuccessEvent and UserLoginFailureEvent (the login and external-login pages), UserLogoutSuccessEvent (the logout page, EndSessionEndpoint and SamlSingleLogoutEndpoint); and as of 0.36.0 the seven for the endpoints only machines call, plus the first fault event — ApiAuthenticationSuccessEvent/…FailureEvent (ApiSecretValidator, mirroring the client validator's two-exit funnel, with the endpoint raising the failure for a request that presented no credential at all and so never reached the validator), TokenIntrospectionSuccessEvent/…FailureEvent (IntrospectionEndpoint), DeviceAuthorizationSuccessEvent/…FailureEvent (DeviceAuthorizationEndpoint, on every refusal it makes and not only the credential check) and UnhandledExceptionEvent (IdentityServerMiddleware); and, in the same release, the five for the decisions a person makes and the ones made about them — ConsentGrantedEvent/ConsentDeniedEvent from all three screens that ask somebody to approve scopes (the consent page, the device page and the CIBA page, which is item 2b's lesson applied before it could cost anything), GrantsRevokedEvent (DefaultPersistedGrantService, after the removal and after the guard, because a record of a revocation that never ran is the one somebody relies on) and BackchannelAuthenticationSuccessEvent/…FailureEvent (the CIBA endpoint, every refusal through one method). The remainder arrive with their raise sites and not before, and the honest statement of what is left is that this tree has no site for them: they belong to capabilities not built here, and the count of 33 came from an inventory that could not then be re-derived — docs.duendesoftware.com is unreachable from the build environment — so no type was being added to close a number. 0.65.0 re-derived it and the number was wrong: Duende has 28. See the end of this row. 0.38.0 then found three that are this server's own rather than Duende's, by asking the question the other way round: not which of Duende's types are missing, but which decisions here are made in silence. DPoPProofReplayDetectedEvent is the twin RefreshTokenReplayDetectedEvent had been missing since 0.17.0 — this server detects replay on two credentials and had an event for one, which is the half-a-rule shape in the event surface itself; it is Information like its twin, raised for replay alone, and names no client because DPoPProofValidationContext carries none and a member added to hold a value the raise site cannot fill is the defect being catalogued. PushedAuthorizationSuccessEvent/…FailureEvent cover both ends of PAR, and the failure is the uncomfortable one: 0.36.0 went hunting for refusals that leave no trace and found one 0.31.0 had introduced, and then 0.37.0 introduced another — the request_uri ownership check, refusing silently. Of the four ways a reference fails, three are somebody holding a stale link and the fourth is a value that has travelled between clients. The return-URL parser resolves the same references for the interactive pages and deliberately raises nothing, because it runs once per render — declaring them ahead of that would manufacture §1e's defect on purpose. The first six all described clients and tokens, and the gap that left was not a missing feature so much as a missing floor: a wrong password on the shipped login page set a message on the model and re-rendered, raising no event and writing no log line, so a run of guessed passwords left no trace of any kind for a host to count. Sign-out was the recurring half-a-rule shape waiting to happen — three paths end a session and only one is a page — so all three raise it, and the endpoint reads the principal before dropping the cookie because afterwards there is nothing left to name them. Two of the shipped raise-site choices are load-bearing and neither is obvious: the events are raised where a user is authenticated rather than where the cookie is written, because DefaultUserSession re-issues the cookie to persist the session id and again per client, so an interception at SignInAsync would report one sign-in three times; and Provider is null for a local sign-in rather than a placeholder, because a filler makes a query for federated sign-ins match every row. EventIds are this server's numbers, not Duende's: 1000/1001 shipped as client authentication in 0.12.0, and renumbering them into Duende's layout would silently change what an existing alert rule matches — which is the failure the no-reuse rule exists to prevent. 0.36.0 found the shape one level up from a member: EventsOptions.RaiseErrorEvents had shipped in 0.12.0 and DefaultEventService.CanRaiseEventType read it, but no event in the tree was ever of type Error — so the flag gated a category with no members, and a host could switch it on, register a sink and correctly receive nothing forever. A read control that cannot change an outcome is §1e's third shape, and this one was visible from an enum rather than from a stack trace. The same release closed the other half of 0.31.0's introspection fix: the refusal it introduced is answered active: false, which RFC 7662 makes right and which is indistinguishable from a token that merely expired, so an API reaching for tokens minted for somebody else left no trace anywhere. The reason is carried on the event and never on the response, and no introspection or device event carries a token, a user code or a device code. ConsentGrantedEvent.Scopes is what was granted and never what was requested — those differ the moment somebody unticks a box — and the pair of "yes with nothing ticked" cases is asserted both ways: a required scope has no checkbox to untick and is added back, so that click grants exactly it, while with nothing required the grant is empty and all three screens answer access_denied. That correction came from CI: the test asserted a denial, the server answered a grant, and the server was right. 0.51.0 added three more of this server's own, by the 0.38.0 method rather than by working toward 33 — and none of the three moves that number, which is the point. SamlSignOnSuccessEvent/SamlSignOnFailureEvent under a new EventCategories.Saml: this server speaks two protocols and audited one. Issuing an assertion is this server vouching for a user's identity to a third party, which is the same act a token is, and TokenIssuedSuccess/Failure have recorded the OIDC half since the event system shipped — while the single sign-on endpoint issued assertions and refused six ways in silence. Every refusal funnels through RedirectToErrorAsync, so one raise site covers five; the sixth is NoPassive, delivered to the service provider as a signed SAML response rather than shown to a person, and it raises separately, because recording only the five that happen to share a helper is the rule applied to the half with the convenient shape. The failure event's ServiceProviderEntityId is nullable and null is the useful case: a request this server could not read far enough to attribute is a different thing from a registered partner sending something wrong, and a log line reading "malformed" says neither. Stated precisely, because the loose version was false: it is not that the SAML assembly raised no events — single logout has raised UserLogoutSuccessEvent since 0.41.0 through ISignOutService, and the SP side reaches UserLoginSuccessEvent through the external-login page. The silence was the IdP sign-on path, and the wrong sentence about it would have been §1e's own shape. BackChannelLogoutFailureEvent completes the sign-out arc's audit half: a logout token that could not be delivered was reported to the log and to nobody else, and what it names is a session this server believes is over and a relying party believes is live — the state somebody has to find before a user reports it. One per relying party, because the sends are isolated and a batch three-quarters delivered is not the same fact as one not delivered at all; and a caller that cancels raises nothing, because that is the caller's decision and writing it against the client would put a fault in the record that never happened. 0.57.0 closed the one 0.51.0 left named, and the modelling decision it was waiting on came out as three events rather than one. A session ends three ways here and one of them reached the sink: a person signing out raises UserLogoutSuccessEvent from all three paths that own a browser, while ISessionManagementService.RemoveSessionsAsync — the seam an administrative surface is built out of — and the 0.13.0 expiry sweep each deleted the row, revoked the grants, notified the relying parties and told the host nothing. The administrative one is the action an operator is most likely to be asked to account for afterwards, and it was the one with no trace. SessionRevokedEvent and SessionExpiredEvent are separate types and separate EventIds because a sign-out is the person's own decision, a revocation is somebody else's decision about them, and an expiry is nobody's: an operator does something different about a rise in each, and note 15's rule is that a host keys its alerting on the id, so folding them behind a reason field would put the distinction somewhere no alert rule can read. Both stay in EventCategories.Authentication rather than earning one — SAML earned a category because an operator filtering the stream is asking about one protocol or the other, whereas a session ending is the same surface as a session beginning whatever ended it, and a new category would mean the authentication filter stopped showing every way a session finishes. Raised per session and never per call or per pass, because a filter may match several and an event per pass would report an idle deployment as a steady stream of ending sessions; and raised before the cleanup that follows, because the row is already gone by then — GetAndRemove means gone — so the fact is settled whatever happens next, and a relying party that cannot be reached must not turn "this session ended" into silence, which is what BackChannelLogoutFailureEvent has been for since 0.51.0. The third change is not a new event at all: two places in this tree remove every grant matching a filter, DefaultPersistedGrantService raised GrantsRevokedEvent and the session-coordination path did not — note 11 again, a rule applied to one of its halves — and the event's existing members already carried exactly what that site knows, with ClientId null because every client of the session went rather than one. 0.63.0 ran the 0.38.0 method over the whole tree for the first time since 0.51.0 — eleven releases — and it came back with two, neither of which moves the 21. The first: DefaultSenderConstraintValidator refuses six ways and told nobody at all, not an event and not a log line, because the class held no logger. Every one of those six means a token carrying a cnf was presented by a request that could not demonstrate possession of the key or certificate it names, which is a token being used by somebody it was not issued to; this server detects the misuse of three credentials and had events for two, so 0.45.0 built the detection and left the audit half unbuilt — §1e's shape standing inside the surface whose job is noticing. SenderConstraintViolationEvent carries the reason rather than splitting into six types, the opposite call from 0.57.0's three session events and for the opposite reason: there an operator does something different about a revocation and an expiry, here all six prompt one investigation, so the reason is diagnostic rather than an alerting key. It names the client and subject the token was issued to — the useful question is whose token somebody else is holding — and never the token, which now has a test rather than a comment. The second: SAML has two roles and two operations, and 0.51.0 audited one cell of four. SamlAssertionRejectedEvent and SamlLogoutFailureEvent close the other three. The first is a separate type rather than a reuse of SamlSignOnFailureEvent because the peer it names is an identity provider, and it names the one the scheme is configured to trust rather than one read out of the message — a refused response is exactly the case where what the message claims about itself cannot be relied on. The second is one type for both roles, with the argument against recorded in its remarks. And a line drawn deliberately: a request carrying no SAML message at all records nothing, because the endpoint is named in published metadata and an event per crawler turns the record into noise. 0.65.0 closed this item, and it began by measuring the number the item had been graded against for thirty releases. docs.duendesoftware.com is still unreachable; nuget.org is not, and was never tried. The package is the better oracle anyway — it is the artefact and documentation lags it — so the inventory was derived twice from Duende.IdentityServer 8.0.1 itself, from the XML documentation file shipped beside the assembly and from a scan of the assembly's own metadata strings. They agree on 28, not 33. Of those 28: eighteen are here under the same name; five are here under this server's own name for the same concept — BackchannelAuthentication{Success,Failure}Event against our BackChannel… with a capital C, SamlSso{Success,Failure}Event against our SamlSignOn… because this repository says signin and never login, and SamlSloFailureEvent against our SamlLogoutFailureEvent; and five are absent as names. Reading the raise sites collapses that five to three: SamlAuthnRequestValidationFailureEvent and SamlLogoutRequestValidationFailureEvent are what SamlSignOnFailureEvent and SamlLogoutFailureEvent already record — the authn-request validator's six refusal reasons funnel into the first, and 0.63.0 gave the second the logout endpoint's refusals. InvalidClientConfigurationEvent has no site and will not get one: nothing in this tree validates client configuration, there is no IClientConfigurationValidator, and an event reporting on a capability that does not exist is §1a's own shape — note 19's rule, a deferral names a reason rather than a destination. The remaining two had sites, and both were silences rather than gaps in a count. SamlSingleLogoutSuccessEvent (Duende's SamlSloSuccessEvent): sign-on had a success event and logout had only refusals, and the objection — that a completed logout reaches UserLogoutSuccessEvent through the shared sign-out sequence — is true and is about the session rather than the exchange. That event is raised on the first pass, when the session ends; the sequence finishes several browser hops later, or never, and a logout that completed and one that stalled at a hop the browser never came back from leave exactly one of them each. Note 34 turned on this tree's own surface. It is raised where the sequence ends and even when the requester cannot be told the answer, because the logout did complete. SamlLogoutNotificationFailureEvent is the serious one, and it is not given Duende's name for it: 0.61.0's AdvanceAsync has a continue for a participant it cannot post to, the remarks there argue the skip is right, and they are right — holding the sequence open for a partner that can no more be told than an unregistered one would leave the requester unanswered forever. The partner's session is still live, and the only record of that was a loop iteration ending early. That is the fact BackChannelLogoutFailureEvent has named on the OIDC side since 0.51.0 — a session this server believes is over and a partner believes is live — so this is note 11 across protocols, which is how it stayed invisible: the rule had been applied to the protocol whose notification is a POST this server watches fail, and not to the one whose notification is a form in somebody's browser. The same silence stood in the other role, where SamlSpOptions.IdpSingleLogoutUrl is unset and 0.44.0 left a log warning saying the upstream session is still live; one event covers both roles, carrying Scheme, for SamlLogoutFailureEvent's reason. It carries a reason rather than splitting the way Duende does — unregistered, no logout URL, or a URL the redirect-target policy refuses — because every one of the three prompts the same investigation and differs only in which registration to fix; Duende's InvalidSamlServiceProviderConfigurationEvent would be an accurate name for two of the three and wrong for the third, and a name right for part of what it covers is what §1 keeps finding. What closes the item is not the arithmetic: it is that every one of Duende's 28 is now either here, covered under another name, or absent with a reason recorded, and nothing is left that this tree has a site for 0.117.0: 26 of 29 — InvalidClientConfigurationEvent (ErrorStart + 1) and InvalidSamlServiceProviderConfigurationEvent (ErrorStart + 3), raised by the client and SAML service provider validating stores. Both had been declined in 0.65.0, the first for want of a validator and the second as a misread rival name; see the configuration validators row. 0.146.0: the payloads, measured from Duende's IL (re-measure release 21). Event.ProcessId, PrepareAsync (protected internal virtual, awaited after the fields are filled and before the sink, Duende's order) and Duende's JSON ToString; ClientName on eight events, Endpoint by Duende's names, the refusals' Scopes, Error, ErrorDescription and SubjectId, RequestedScopes on both consent events, introspection's ClaimTypes, TokenScopes, ApiScopes and IsActive, ClientId on the sign-in events — each set at the raise site. By the decision of 2026-10-13, Duende's names and shapes without its credentials: TokenIssuedSuccessEvent.Tokens (Duende's nested Token), TokenRevokedSuccessEvent.Token and the introspection events' Token carry sha256: and the base64url SHA-256 of the token (TokenFingerprint), never the token — Duende's Tokens carries every token whole and its other two the last four characters — and no option puts a token back; UnhandledExceptionEvent.Details is the type and message, and Duende's whole ToString() only under EventsOptions.IncludeStackTraceInUnhandledExceptionEvents. Ours, recorded: Scopes on the refusals is a collection; TokenType is the kind revoked, not the hint; ClientId on a SAML sign-in is the entity id. Not taken: RedirectUri (authorize-endpoint token events, release 23), ProviderUserId/GrantedScopes/ConsentRemembered (here as ProviderSubjectId/Scopes/Remembered), Obfuscate. Found: Duende's federated UserLoginSuccessEvent constructor overwrites Endpoint = "UI" with "Token", falling through. 0.157.0 — the CIBA events by Duende's name and in its category (re-measure decisions 4 and 7), measured: BackchannelAuthenticationSuccessEvent and …FailureEvent (BackChannel… until then) in EventCategories.BackchannelAuthentication ("BackchannelAuthentication", new), with this server's BackChannelAuthenticationNotificationFailureEvent; all three were in Device. Names and ids unchanged (the ids this server's, 5010–5012, where Duende's are 6000–6001). Breaking for a filter on the category. 0.162.0: DeviceAuthorizationFailureEvent.ErrorDescription, the third of Duende's three (the token and CIBA refusals had it since 0.146.0), the request validator's description set at the raise site as Duende sets it from the validation result; null for the endpoint's own refusals before the validator, which Duende raises no such event for. The device endpoint still answers with the error code alone, where Duende's adds the description. 0.165.0 (breaking, plan row 41): EventTypes take Duende's values, measured — Success 1, Failure 2, Information 3, Error 4 (0–3 until then). Nothing here writes the number (ToString, the default sink and the metric tag write the name); a host's sink that stored it reads earlier rows one off. EventIds stay this server's, by their rule. |
IAuthorizeInteractionResponseGenerator / InteractionResponse |
✅ | ✅ as of 0.142.0, Duende 8.0.1's, measured (AuthorizeInteractionResponseGenerator, AuthorizeEndpointBase, AuthorizeHttpWriter). The endpoint's login, consent and prompt=none decisions were private to it, so changing one meant replacing the endpoint; they are the open default's ProcessCreateAccountAsync, ProcessLoginAsync and ProcessConsentAsync now, in Duende's order, with AddAuthorizeInteractionResponseGenerator<T>(). The generator reads ValidatedAuthorizeRequest.PromptModes and MaxAge, so a custom validator that changes them is obeyed. Its answers: an interaction error (Duende's seven, unmet_authentication_requirements among them) to the client and any other to the error page; the login, consent and create-account pages (prompt=create, CreateAccountUrl); a page of the host's own (RedirectUrl, CustomRedirectReturnUrlParameter). Ours: a consent decision is read whenever there is one and not recorded again (the consent page recorded it); prompt=none errors carry no description, as Duende's carry none. |
IHttpResponseWriter<T> / AddHttpWriter |
✅ | ✅ as of 0.147.0 (re-measure release 14), Duende 8.0.1's, measured: IHttpResponseWriter<in T> where T : IEndpointResult and AddHttpWriter<TResult, TWriter>(), registered transient; asked by the middleware for the result's type, the result writing itself when none is registered. JsonResult, StatusCodeResult and RedirectResult expose what they write. Ours, recorded: results here are this server's generic ones, not one per endpoint, and Duende's EndpointResult<T> base is not taken |
IAuthorizeResponseGenerator / AuthorizeResponse; token-issued events from the authorize endpoint |
✅ | ✅ as of 0.149.0, Duende 8.0.1's, measured (AuthorizeResponseGenerator, AuthorizeResponse, AuthorizeEndpointBase.RaiseResponseEventAsync/RaiseFailureEventAsync, AuthorizeHttpWriter, AuthorizeResponseExtensions.ToNameValueCollection). The code was issued privately by the endpoint; the open default's CreateResponseAsync, CreateCodeFlowResponseAsync and CreateCodeAsync issue it now, registered TryAddTransient as Duende's (no builder method, as in Duende). Every answer to the client is written from an AuthorizeResponse in Duende's order — error or code, state, session_state, iss, then CustomParameters. The endpoint raises TokenIssuedSuccessEvent for a code and TokenIssuedFailureEvent for every refusal, to the client or to the error page (the validated request's client, else the one named; authorization_code once the response type was read). Found: Duende's error answers carry session_state; taken. Ours: code flow only (implicit and hybrid are non-goals), chosen by response_type; the code's scopes are RequestedScopes as the interaction generator narrowed them, not ValidatedResources.RawScopeValues; the session id from IUserSession; a custom parameter never replaces one this server writes (Duende's NameValueCollection adds a second value); iss on error answers too (RFC 9207 §2), where Duende's sets Issuer only on a code; no StateHash/WasConsentShown on the code; the events carry no token (decision 1), where Duende's success event carries the code — here its fingerprint, in Tokens as code. With release 21's members, filled as Duende's authorize constructors do: Endpoint ("Authorize"), RedirectUri (new on both events), ClientName, and SubjectId/Scopes on the failure event. |
IUiLocalesService; UserInteractionOptions.AllowOriginInReturnUrl |
✅ | ✅ as of 0.149.0, Duende 8.0.1's, measured (DefaultUiLocalesService, AuthorizeInteractionPageHttpWriter.BuildRedirectUrlAsync, AuthorizeHttpWriter.RedirectToErrorPageAsync, OidcReturnUrlParser/SamlReturnUrlParser.IsValidReturnUrl, ConfigureInternalCookieOptions.ExtractLocalUrl). The default writes the first ui_locales tag naming a supported UI culture, matched exactly, to the request-culture cookie; the authorize endpoint tells a local interaction page and the error page, and ValidatedAuthorizeRequest.UiLocales carries the value. A page that is not a local path is not told the locale and, under AllowOriginInReturnUrl, is handed an absolute return URL (this server's origin in front) — ours, decided 2026-10-14: Duende hands one whatever the option, which would stop an absolute LoginUrl on this server resuming requests until the option is on; off, every page gets the relative one it always did. AllowOriginInReturnUrl (default false) lets both parsers take this server's origin, ignoring case, off an absolute return URL (ReturnUrlPath.WithoutOrigin); the SAML parser's change is a localized edit ahead of its two path checks. Found: an absolute LoginUrl/LogoutUrl was handed to the session cookie's LoginPath/LogoutPath and threw; now a local path only, as Duende's ExtractLocalUrl. 0.156.0 — the end-session endpoint too (re-measure release 30), Duende 8.0.1's EndSessionHttpWriter, measured: a local LogoutUrl is told the validated request's ui_locales — refused or not, as Duende's writer reads ValidatedRequest whatever IsError says — on both of this server's paths (the prompt, and the logout page after a verified client's sign-out); a logout page elsewhere is not. ValidatedEndSessionRequest.UiLocales and LogoutRequest.UiLocales carry it; EndSessionEndpoint keeps its constructor beside one taking IUiLocalesService. Ours, recorded: a local page is a path with one leading /, as the authorize endpoint reads it — Duende's IsLocalUrl also takes ~/ and refuses control characters. |
IReturnUrlParser |
✅ | ✅ as of 0.19.0, and like IServerUrls it arrived with a defect rather than as a bare seam. The interaction service recognised and resolved a return URL itself, and the only shape it knew was /connect/authorize — so when the SAML single sign-on endpoint redirected an unauthenticated browser to the login page with a return URL naming itself, the page refused it and redirected to the site root. The user signed in and the service provider was never answered: SP-initiated SAML sign-on worked only for a user who already had a session, which is the one case that redirect does not exist to serve. Now one parser per protocol, all of them asked — OidcReturnUrlParser from AddIdentityServer, SamlReturnUrlParser from AddSaml — with ReturnUrlParser aggregating and ReturnUrlPath holding the local-path comparison once rather than once per protocol, because the copy of that check which drifts is a redirector. ParseAsync answers IAuthenticationContext, which makes GetAuthenticationContextAsync protocol-agnostic in fact and not only in its doc comment, and the login page reads it — the first thing ever to. #78 |
IHandleGenerationService |
✅ | ✅ as of 0.18.0. Was seven calls to a static CryptoHelper.CreateHandle across three assemblies, so a host needing a different length, alphabet or source of entropy had nothing to replace. The five handles a client is actually given — authorization codes, reference tokens, refresh tokens, device codes, server-side session keys — now resolve it. Refresh-token family ids deliberately do not: they group a lineage inside this server's own storage and are never handed to anyone, and one of the two sites runs during deserialization where an async host-replaceable call has no business being. #76 0.158.0: Duende's GenerateAsync(ct, length), measured (length bytes, 32 by default; its three callers pass 32), as an overload in Duende's argument order beside GenerateAsync(ct), which still answers 32 bytes and which this server's callers still ask; a default interface method, so a host's generator keeps compiling and throws NotSupportedException when asked a length it does not implement. Ours: base64url where Duende's are hex; a length below one refused (ArgumentOutOfRangeException), where Duende's hands it on unchecked |
IPersistedGrantService (account-management view) |
✅ | ✅ as of 0.24.0, and folding it in had cost something rather than only being a shape. All three grant members on the interaction service read the subject off the ambient HttpContext, which is right for the account screen they were written for and wrong for everything else — an administration tool disconnecting an application for a user, a background job revoking what a deactivated user still holds. Both had nothing to call, and the ambient members did not say so: the list came back empty and the revocation did nothing, silently. IPersistedGrantService takes the subject; the interaction service delegates all three. Its query still answers empty with nobody signed in, because that question has a true empty answer; its two commands now refuse, which is the refusal GrantConsentAsync three methods above them had always made. 0.114.0 found the list itself narrower than Duende's, measuring the grant description chain. It held consents alone; Duende's GetAllGrantsAsync lists every unconsumed consent, authorization code, refresh token and reference token, merged per client by its Join — scopes together, earliest creation, latest expiry with none beating any, first description — and leaves out a row it cannot read. Taken as measured. A client used without a consent screen held tokens the user's list did not show, and so could not be disconnected from it. The description chain, found untracked 0.112.0 and closed 0.114.0: ConsentResponse.Description reached the consent row (0.20.0) and nothing else. It now travels as Duende's does — onto the authorization code (through the consent message and the validated request), the device code, and the backchannel request (0.113.0); from each, through the validated token request and TokenCreationRequest, onto the access token and the refresh token; from a refresh token to the next and to the access token it keeps; and onto every stored row, the code, refresh-token (store and update) and reference-token formats carrying it. Where Duende's validated token request holds the redeemed grant and reads the description off it, this one is flattened, as it already was for the session id, so the validator copies it. The three approval pages ask for it, as Duende's templates do — kept to 200 characters, the SQL Server dialect's column, which a host's own page setting a longer one would still overrun. Driven end to end through the device, CIBA and consent pages to the list, and through a refresh. #88 |
IServerUrls |
✅ | ✅ as of 0.18.0 — and it arrived with a defect to fix rather than as a bare seam. Three sites built this server's own URL and two agreed: IssuerNameService and the DPoP htu used HostString.ToUriComponent(), PrivateKeyJwtSecretValidator used HostString.ToString(). Those differ on an IDN host — Unicode against punycode — so a client that addressed its assertion to the URL it had actually dialled was refused for an audience mismatch it never made. All three now reach one construction. #76 |
IReplayCache |
✅ | ✅ as of 0.17.0 — IReplayCache with InMemoryReplayCache, swept on read and keyed by purpose, so a DPoP proof jti cannot be presented twice. This row read ⬜ until 0.19.0 while §5 recorded the same interface as shipped: the matrix disagreeing with itself, found by reading it rather than by anything failing. As of 0.131.0 the default is Duende's DefaultReplayCache over IDistributedCache, with a distributed memory cache registered as Duende registers one, so a host with several instances shares it by registering a real cache; the device-flow and CIBA polling throttles moved there too (Duende's Distributed…ThrottlingService, keyed by the codes' hashes rather than the codes, each with the interval its own response advertised). Until then each instance's replays and polls were its own |
IIdentityServerTools |
✅ programmatic token issuance | ✅ as of 0.25.0 — IssueJwtAsync (twice, one naming the issuer) and IssueClientJwtAsync. A host that needed a signed token for its own reasons had to reach past this library to its signing key, which is how a deployment ends up with two issuers and two rotation schedules for one identity. These always produce a JWT: ITokenService.CreateSecurityTokenAsync answers a reference-token client with a stored handle, which is right at the token endpoint and wrong for a method with Jwt in its name, so this goes through ITokenCreationService instead and a test fails if that changes. Refuses loudly with no issuer, where the logout sweep warns — the sweep is housekeeping nobody is waiting on, and this returns a value the caller is about to use. #91. As of 0.135.0 IssueJwtAsync(lifetime, issuer, tokenType, claims), Duende's: the type decides typ as for the endpoints' tokens — access_token at+jwt, logout_token logout+jwt, any other JWT — and the issuer-only overload passes access_token. Still ours: IssueClientJwtAsync requires an enabled client and puts audiences in the token's audience, where Duende's looks nothing up and adds the static {issuer}resources audience — release 22. ✅ 0.150.0 (re-measure release 22), IssueClientJwtAsync as Duende's, measured: (clientId, lifetime, ct, scopes, audiences, additionalClaims), no client lookup, the claims a set (ClaimComparer) — those given, then client_id and the scopes — and the static {issuer}/resources audience first when EmitStaticAudienceClaim is on, then those given; Duende's four-argument constructor (options, no client store) beside the one it had (the client store, no longer consulted; the options as the defaults), which is kept. Breaking for a caller only in that an unknown or disabled client is no longer refused (recorded in the plan); this server's argument order is kept, with Duende's (the token third, required) as an overload beside it and a default interface body (decided 2026-10-14), and code constructing the tools is not affected. Ours: audiences on the token's audience list, which token creation writes, where Duende's adds aud claims; our ClaimComparer (type ordinal, issuer ignored) where Duende's compares type ignoring case and the issuer |
IBackchannelAuthenticationUserValidator / …UserNotificationService / IBackchannelAuthenticationInteractionService |
✅ | ✅ as of 0.27.0, and the first services here that ship with no default implementation at all. What a login_hint means, and how to reach the person it names, are the two questions this library is in no position to answer, so both are the host's and startup refuses without them. The notification seam is the load-bearing one: a no-op default would turn "this host has not finished wiring CIBA up" into "CIBA is slow today" — every request accepted, every one expiring unanswered, indistinguishable from a user ignoring their phone. The interaction service is the device flow's counterpart with one difference: a device code is answered by whoever holds it, so any signed-in user may approve one, while a backchannel request names its user before anyone is asked, so every member takes the answering subject and refuses a request belonging to somebody else. Without that, "authenticate this person" would mean "authenticate whoever answers". #95 0.157.0 — Duende's spelling (re-measure decision 4), measured: these three, IBackchannelAuthenticationThrottlingService, DistributedBackchannelAuthenticationThrottlingService and BackchannelAuthenticationUserValidationResult are spelled Backchannel (BackChannel until then; breaking — a rename). Kept, as Duende spells them: BackChannelAuthenticationRequest and its store. Kept by decision 4: the interaction service's name, BackChannelAuthenticationInteractionService (Duende's DefaultBackchannelAuthenticationInteractionService). Kept, this server's own: the request context, the user validation context, the in-memory throttle, the notification HTTP client; 0.157.0 — the members too (re-measure row 35, decided 2026-10-14): EndpointsOptions.EnableBackchannelAuthenticationEndpoint, EventIds.BackchannelAuthenticationSuccess/Failure (ids kept), ProtocolRoutePaths.BackchannelAuthentication (still connect/ciba: no URL moves) and EndpointNames.BackchannelAuthentication, whose value is Duende's BackchannelAuthentication — the telemetry endpoint tag and span name change with it. Breaking. |
IUserCodeService / IUserCodeGenerator |
✅ | ✅ Duende's shape as of 0.83.0: IUserCodeService.GetGenerator(type) over registered IUserCodeGenerators, chosen by Client.UserCodeType ?? DeviceFlowOptions.DefaultUserCodeType, a code in use regenerated up to RetryLimit and the request failing after — all measured from Duende's DeviceAuthorizationResponseGenerator. Two generators ship: Readable, this server's twelve characters and still the default, and Numeric, Duende's nine digits (GetInt32(100000000, 1000000000), retry limit 5). The default type is the one deliberate difference — Duende's is Numeric, about 30 bits against 51 — and the last registration of a type wins where Duende's lookup takes the first, because this server registers its own inside AddIdentityServer. 0.23.0 declined the keyed lookup as "one key and a client property nothing varies"; the client property arrived and varies it, which is the condition the decline named. Before it, nothing checked a new code against the store, so a generator that repeated itself handed two devices one code. What closing the row found instead was a defect in the code's other half. A user code is read off one screen and typed into another and nothing canonicalised it, so the comparison was left to the store — and the three stores disagree: the in-memory one compares ordinal, SQLite by binary collation, and SQL Server's default collation is case-insensitive. The same typing worked or failed depending on which store a host had configured. UserCode.Canonical now runs where a code is generated and where one comes back, and every store sees one form. #86 0.177.0, recorded: no limit on wrong user-code guesses at the device page, as in Duende's templates (RFC 8628 §5.1 asks for rate limiting). The page requires a signed-in user and the default code is ~51 bits; a host choosing Numeric (~30 bits) rate-limits the page — the guide says so. Left for a decision on a built-in limit. |
9. Hosting and operations
| Feature | Duende | Here |
|---|---|---|
| Dynamic identity providers | ✅ Hosting.DynamicProviders, 9 public types (re-counted 0.97.0; this column said 6) | ✅ as of 0.110.0 — 0.47.0 for adding and removing without a restart, 0.68.0 for editing in place, re-graded 🟡 in 0.97.0 when measured against Duende's package, and closed by 0.110.0's dynamic SAML; see the end of this cell. IdentityProvider / OidcProvider, IIdentityProviderStore with InMemoryIdentityProviderStore, DynamicProviderOptions with a provider-kind registry, DynamicProviderOptionsConfigurator binding a stored row to a handler's named options, and DynamicAuthenticationSchemeProvider decorating the framework's. A decorator rather than a parallel lookup, and that is the whole design: three readers already ask what schemes exist — the sign-in chooser, the idp: home-realm hint matched against it, and ISignOutService resolving the idp claim to decide whether to carry a sign-out on to the provider that started the session — and a fourth, GetRequestHandlerSchemesAsync, is the one a callback needs and the easiest to miss. This is note 20's case in point. Taken before 0.42.0 it would have shipped a scheme users could be offered, sign in through, and then silently not be signed out of: 0.42.0's defect, reintroduced by the feature that followed it. The test that says otherwise drives a stored provider through sign-in and out. 0.68.0 closed the in-place edit, and the recorded reason was about the wrong mechanism. It read: the scheme provider must be a singleton, because AuthenticationMiddleware takes IAuthenticationSchemeProvider in its constructor and is built from the root provider when the pipeline is, so a per-request chain giving fresh reads fails scope validation at startup rather than serving a stale row. Every word true, and all of it about scoping: ResolveDynamicAsync reads the store through a fresh scope on every lookup and overwrites the cache entry, so the resolved row was never stale. The framework's named-options cache was — it builds a scheme's options once and keeps them — and being a singleton made it reachable from the singleton that resolves schemes rather than out of reach. DynamicProviderCache.Remember now reports whether the row changed and the lookup drops that scheme's cached options, so the next handler is configured from the new row; no lifetime changed. Two decisions carry it. The drop is driven by a change rather than by a lookup, because rebuilding options every time would re-fetch an OpenID Connect discovery document every request — the ConfigurationManager that caches it lives on the options object being discarded — so only Type and Properties are compared, and a display-name edit reaches the scheme, which is rebuilt anyway, rather than the options. And the comparison is against a snapshot rather than the previously returned object, because a store may hand back the same mutable instance twice and comparing it with itself reports no change however much it was edited. The README had named the remedy — "an invalidation hook rather than a different lifetime" — while the code's remarks two files away described a wall, and the two went unreconciled for twenty-one releases; IdentityProvider's own summary meanwhile promised a host could "add, change or remove without a restart", which only became true here. OpenID Connect is the shipped kind; a host adds its own through the same public AddDynamicProviderType<THandler, TOptions, TIdentityProvider>, which is what the fixture does — and is the check that the mechanism is one rather than OpenID Connect with extra steps. Re-measured in 0.97.0, and the ✅ did not survive it. The follow-up was DynamicProviderType's three unread members; measuring them against Duende 8.0.1's package rather than against this tree showed the shape was not Duende's. Its DynamicProviderOptions.DynamicProviderType has no Type (a kind's name is its key), reads OptionsType to evict a scheme's cached options and IdentityProviderType for the serializer its identity-provider store cache uses, and carries a CopyConstructor for IIdentityProviderFactory. 0.97.0 took the shape: Type gone, OptionsType read by the eviction as Duende's IdentityProviderOptionsMonitorCache reads it, Find renamed FindProviderType. Measured at the same time, absent here: CachingIdentityProviderStore<T> (HybridCache, with PolymorphicJsonTypeResolver — IdentityProviderType's consumer), NonCachingIdentityProviderStore<T> and ValidatingIdentityProviderStore<T>; IIdentityProviderFactory and CopyConstructor; DynamicAuthenticationScheme; dynamic SAML (SamlAuthenticationOptions); and on DynamicProviderOptions, PathMatchingCallback, SignInScheme, SignOutScheme, SignOutSchemeSetExplicitly and ProviderTypes — the sign-in scheme is hard-coded in DynamicProviderOptionsConfigurator where Duende reads the option. Present under other names: ConfigureAuthenticationOptions<,>/ConfigureAuthenticationContext<,> as IConfigureDynamicProvider<TOptions>, DynamicAuthenticationSchemeCache as DynamicProviderCache, and IdentityProviderOptionsMonitorCache folded into DynamicProviderOptions. A ✅ graded against a count nobody re-derived is note 15's shape; the row is 🟡 until those land. 0.102.0 took the store decorators, measured from 8.0.1: ValidatingIdentityProviderStore<T> with IIdentityProviderConfigurationValidator, DefaultIdentityProviderConfigurationValidator (kind registered, scheme present, then OpenID Connect's authority, client id, response type and scope — the first two stop at the first failure, the four all run so the last names the message), the validation contexts and the error event; and CachingIdentityProviderStore<T> over a HybridCache keyed ConfigurationStoreCache, as Duende's is — but taken as the abstraction ASP.NET Core ships, not
Microsoft's implementation package, which Duende depends on: a host registers its own under the key, and without one an internal in-memory HybridCache
private to the process is used, which is what Duende's default amounts to when no distributed cache is registered (Duende's would also pick up a registered
IDistributedCache as a second level; here a host gets that by registering Microsoft's) — which gives IdentityProviderType its reader — a serializer that rebuilds a cached row as its kind's registered type, where Duende uses PolymorphicJsonTypeResolver. Wired where Duende wires them: AddIdentityProviderStore<T> and AddInMemoryIdentityProviders put the validator in front of the store, AddIdentityProviderStoreCache<T> the cache in front of that. Two divergences, chosen. The cache's duration is an argument, AddIdentityProviderStoreCache<T>(duration) with Duende's 60 minutes by default, where Duende reads CachingOptions.IdentityProviderCacheDuration — this server has no Caching group and puts a cache's duration where it is switched on, as AddCorsPolicyCache does (§10). And NonCachingIdentityProviderStore<T> is declined as a type, not as behaviour: what it does in Duende — drop a changed row's handler options — is done here by DynamicAuthenticationSchemeProvider, which every registration reaches, including a store registered by hand, where Duende's decorator reaches only stores registered through its builder methods; as a type here it would be an empty pass-through. A host-registered store is not validated, as in Duende. A behaviour change: an OpenID Connect row with no authority was offered and failed with a 500 when picked, and is now no such scheme. 0.103.0 took IIdentityProviderFactory and CopyConstructor, measured: the factory (internal there and here, registered by AddDynamicProviders) makes the kind's type from a row held as the base IdentityProvider through the kind's CopyConstructor (internal too), or null; IdentityProvider(string type, IdentityProvider other) copies a row of the named kind with properties of its own and refuses another kind; OidcProvider(IdentityProvider) is the oidc kind's. AddProviderType gained Duende's two refusals — a type with no public ctor(IdentityProvider), and a kind registered twice, which until then silently replaced the first; the second found this repository's own dynamic-provider fixture registering oidc over a host that already had. Duende's only caller of the factory is its EF store, a non-goal; here the cache serializer and the OIDC configurator read base rows through it, and a host's store is the intended caller. 0.104.0 took the DynamicProviderOptions members, and the routing one of them needed. SignInScheme (idsrv.external) and SignOutScheme (idsrv when unset) replace the two values hard-coded in DynamicProviderOptionsConfigurator and DynamicOidcProviderConfigurator. PathMatchingCallback is read by DynamicSchemeAuthenticationMiddleware, Duende's measured and written here — no dependency on Duende — in UseIdentityServer before the protocol endpoints: the scheme from the callback or the segment after PathPrefix, handed to that handler alone. Measuring it found the larger difference: Duende's scheme provider lists only static schemes for request handling, and this one listed every stored provider, so the authentication middleware asked each of them about every request and every request the server answered read the store's list and each row. The list now holds the static schemes, as Duende's does; GetAllSchemesAsync still lists stored providers, which the chooser, the idp: hint and sign-out here rely on. ProviderTypes is internal in Duende and read only by its polymorphic cache serializer, which the factory replaces here — not surface, not taken. SignOutSchemeSetExplicitly is read only by Duende's ASP.NET Identity package, and is deferred with it (below). 0.105.0 took DynamicAuthenticationScheme, measured: an AuthenticationScheme named and displayed as its row, carrying it as IdentityProvider, handed out for every stored provider by GetSchemeAsync and GetAllSchemesAsync; DynamicProviderCache — Duende's DynamicAuthenticationSchemeCache under another name — now remembers the scheme and reads the row through it, as Duende's keeps the scheme, so Remember takes the scheme rather than the row. 0.110.0 took dynamic SAML, the last of the measured absences, in two releases because it rests on a service-provider handler: 0.109.0 gave this server's handler the POST-bound AuthnRequest and the outbound signing algorithm a SamlProvider row can name, so that every field maps onto something real. SamlProvider keeps Duende's eleven property names, defaults and flag spelling (true/false in lower case, only the exact true read as set, WantAssertionsSigned on when absent); ValidateSamlProviderAsync makes Duende's seven checks in its order and words, each ending validation; SamlAuthenticationOptions is filled per scheme from the row by the same IConfigureDynamicProvider<TOptions> mechanism before a host's own Configure, and the handler's options read it first, as Duende's SamlConfigureOptions does; AddSamlDynamicProvider registers saml with SamlSpHandler, and AddInMemorySamlProviders goes through AddInMemoryIdentityProviders. Driven end to end, unlike OpenID Connect: the identity provider's half is a signed response this package's IdP side writes, so a stored row is challenged from the sign-in page and its response posted back to a local session. Three divergences, chosen. A row with no IdP signing certificate is refused (Duende accepts one), because this handler verifies every response against that key; WantAssertionsSigned false is carried and logged, not honoured, because the handler requires a signed assertion and its metadata says so — in Duende's SP it changes only the metadata; and the signed-out callback and metadata are served per scheme under PathPrefix, beside the assertion consumer, where Duende's handler shares one module path across schemes. With no SpEntityId the issuer stands in, as in Duende, and outside a request a host with no IssuerUri has none — the scheme's options then fail to build. What is left is not this row's: SignOutSchemeSetExplicitly is read only by Duende's ASP.NET Identity package and waits with that package's row. #40 item 9 |
| Local API authentication | ✅ Hosting.LocalApiAuthentication, 5 types | ✅ as of 0.46.0, and it is the one item that was safer to build late. LocalApiAuthenticationHandler, …Options, …Events, the IdentityServerConstants.LocalApi names and the two registration methods — a host maps its own endpoint beside the identity server and requires LocalApi.PolicyName, and the token is validated in-process by the same ITokenValidator the protocol endpoints use, against the same keys and issuer. Built on 0.45.0's seam rather than on ITokenValidator alone: the handler runs ISenderConstraintValidator, so this is not a third place a sender-constrained token is honoured for whoever happens to hold it. Had item 10 been taken before §1e row 10, it would have been — and it would have been the first such site written after the two that already existed. Three deviations from Duende, each a decision rather than an omission: ExpectedScope is required, because "any token this server issued" is a door every token in the deployment opens; the scope is checked in the handler rather than left to the policy, so AddLocalApi() alone still guards the API, at the cost of answering 401 where 403 would say more; and IProfileService.IsActiveAsync was not consulted until 0.128.0, a decision that a resource server checks only the token it was given; as of 0.128.0 it is, as Duende's local API asks through ITokenValidator (AccessTokenValidation) — see the IProfileService row. Both registration methods are idempotent — the 0.33.1 shape, found here by the fixture registering what the demo host already had. #40 item 10 TokenMode as of 0.138.0 (LocalApiTokenMode: BearerOnly, DPoPOnly, DPoPAndBearer), Duende's: a token under a scheme the mode does not take is ignored, no result, with Duende's log line. Ours, recorded: the default is DPoPAndBearer where Duende's is BearerOnly; a key-bound token under Bearer is refused in every mode, where Duende's BearerOnly takes it; the challenge names one scheme. Named schemes as of 0.150.0 (re-measure release 22): Duende's AddLocalApi(scheme, configure) and AddLocalApi(scheme, displayName, configure), each scheme with its own options; ours keeps the idempotence per name — a name added twice is configured twice. 0.165.0 (breaking, plan row 41): LocalApi.PolicyName is Duende's "IdentityServerAccessToken", the scheme's name, and AddLocalApiAuthentication adds the policy under it, as Duende's does (measured); it was the scope's name, "IdentityServerApi". The scope and the scheme are unchanged, both already Duende's. 0.174.0 — the challenge has no page. Duende 8.0.1's HandleChallengeAsync, measured, writes the status, WWW-Authenticate and DPoP-Nonce and no body, and Duende ships no error pages; here UseIdentityServerErrorPages (the UI package's, ours) caught the local API's 401 — a host path, not a protocol one — and rendered the status page under the challenge, as the DPoP flow page recorded. It now leaves alone any response carrying WWW-Authenticate. |
| Telemetry — metrics | ✅ Telemetry.Metrics, 3 types | ✅ as of 0.48.0, and the shape differs from Duende's on purpose. Three instruments under one meter named Fdyay.IdentityServer: fdyay.identityserver.events (counter), active_requests (up/down counter) and request.duration (histogram, seconds, because that is the unit OpenTelemetry's semantic conventions pick bucket boundaries for and a duration in milliseconds lands every measurement in the last bucket). Where Duende has a counter per concern, this has one counter derived from the event surface, tagged by event id, name, category and type — because that surface is already audited for completeness (twenty-four types, every one with a raise site, checked release by release), so a new event type is counted the moment it is raised. A parallel list of counters is a second list to keep in step, and the release that forgot to extend it would ship a decision nothing counted — §1e's own shape, avoided by construction rather than by vigilance. The count is taken before EventsOptions is consulted, and that is the load-bearing part: those four flags are off by default because an event carries client ids, subject ids and addresses, and a count tagged with an event's name and id carries none of them. Riding the event pipeline would have meant a host switching monitoring on and receiving nothing, for a privacy reason that does not apply to a count — and the demo host, which leaves every flag off, is the test that says so. The fdyay.event.id tag arrived from a failing test: the metric had been carrying Event.Name, a display string, and not EventIds, which exists precisely to survive a rename and never be reused. Nothing needs switching on — a meter nobody listens to costs an atomic increment. #40 item 11 0.165.0 (breaking, plan row 41): the endpoint tag carries Duende's endpoint names, measured — Jwks, Userinfo, Endsession, Checksession, Saml2 SingleSignOnService, Saml2 SingleSignOnCallback, Saml2 SingleLogoutService, SamlMetadata (Discovery.Jwks, UserInfo, EndSession, CheckSession, Saml.Sso, Saml.SsoCallback, Saml.Slo, Saml.Metadata until then). |
| Telemetry — tracing (ActivitySource) | ✅ | ✅ as of 0.48.0. One ActivitySource named Fdyay.IdentityServer, one span per protocol request, started in IdentityServerMiddleware — the same place the unhandled-exception event is raised, and for the same reason: it is the one place every protocol request passes through. The span is named for the endpoint and carries ActivityStatusCode.Error with the message when the request threw, because a trace showing 40ms and saying nothing about a fault is one a reader has to leave to find out what happened. The endpoint name comes from the router rather than from a handler's type name: EndpointRouter leaves the matched EndpointNames constant on HttpContext.Items, so a dashboard keyed on it survives both a renamed class and a host's path base. Null when nothing is listening, which allocates nothing. 0.165.0 (breaking, plan row 41): the span names are those EndpointNames, now Duende's values; see the metrics row. |
| Sign-in surface: IdentityServerUser, HttpContext.SignInAsync(IdentityServerUser …), IdentityServerAuthenticationService | ✅ public — the way a Duende host's own login page signs a user in; the service decorates IAuthenticationService so any sign-in to the session scheme gets sid, auth_time and idp | ✅ as of 0.108.0 (found untracked 0.107.0), measured from 8.0.1. IdentityServerUser makes Duende's principal — sub, name, idp, tenant, auth_time, one amr per method, then additional claims, de-duplicated; its authentication type is this server's name — and HttpContext.SignInAsync(user[, properties]) signs it in to the resolved session scheme. Duende's decorator has two halves, and the sign-out half was already here by another route: since 0.43.0 the session scheme's handler is wrapped so that any sign-out of it — a host's own HttpContext.SignOutAsync() included — runs ISignOutService's sequence (event, check-session state, coordinated revocation, back-channel logout), which is what Duende's OnStarting callback does. The sign-in half is now in the same wrapper: one identity with a sub or Duende's refusal, the framework's authentication-method claim turned into idp and amr=external, and a missing idp/amr/auth_time filled in (local, pwd or external, now). The shipped login pages sign in through IdentityServerUser, so a local sign-in's tokens carry idp=local and amr=pwd, and an external one an auth_time it never had — Duende's tokens, and a behaviour change. Not taken then: Duende's decorator also creates the session id at sign-in; here it was created on first use, as since 0.12.0. Taken 0.174.0, measured from DefaultUserSession.CreateSessionIdAsync: a sign-in to the session's scheme whose properties carry no session id is given one in the same wrapper, so the cookie is written with it. Not taking it had a visible cost: the first authorization after a sign-in created the id (re-issuing the cookie) and recorded its client (re-issuing it again), so the response set idsrv twice, the first already stale, as the authorization-code, PAR and SAML flow pages recorded; it now sets it once, as Duende's does. A server-side session's row has its id from the sign-in. Ours: a sid claim of the host's own is left to win, as DefaultUserSession reads it first; a new id for every sign-in, where Duende's keeps the session's id and client list when the same user signs in again; the check-session cookie is still written by the authorize response rather than at sign-in; a session with no id — signed in before the upgrade, or to a scheme the handler provider does not wrap — is still given one on first use |
| ASP.NET Core Identity integration (Duende.IdentityServer.AspNetIdentity) | ✅ a package of its own | ✅ as of 0.111.0, Fdyay.IdentityServer.AspNetIdentity (found 0.104.0, tracked nowhere until then). Measured from the 8.0.1 package: AddAspNetIdentity<TUser> (after AddIdentity, refusing in Duende's words a factory not yet registered and a second decoration), the claims-factory decorator (sub, preferred_username, name, email_verified, phone_number, phone_number_verified), Identity's claim types made the protocol's, ProfileService<TUser> with every step protected virtual, ISessionClaimsFilter/DefaultSessionClaimsFilter and SecurityStampValidatorCallback carrying sid, idp, amr and auth_time onto a principal Identity refreshes, Identity's cookies essential and SameSite=None, the default scheme moved from idsrv to Identity.Application where idsrv was it, and UseAspNetIdentityCookieScheme with DynamicProviderOptions.SignOutSchemeSetExplicitly. Driven end to end through a real SignInManager over an in-memory user store (EF Core being a non-goal): tokens naming the Identity user, UserInfo carrying its claims, the session in Identity's cookie, and a security-stamp refresh keeping the same sid and auth_time. Not taken: ResourceOwnerPasswordValidator<TUser>, the password grant being a non-goal. Shape differences, chosen: ProfileService<TUser>'s UserManager, ClaimsFactory and Logger are protected properties where Duende's are protected fields — the same source for a deriving host, and the repository's analyzers refuse visible fields. Found driving it: UserInfo answered every claim as a string, so email_verified was "true" there and true in the id_token; Duende's ToClaimsDictionary converts both, and since 0.111.0 UserInfo uses the token payload's conversion. Beyond Duende's package, by choice, 0.112.0: the shipped login and external-login pages sign Identity's users in. Duende's pages are templates a host copies, one per kind of account; these are a package, so the choice is a service of ours, IUserSignInService — the test users by default (AddIdentityServerUi, TryAdd), SignInManager<TUser> once AddAspNetIdentity is called, in either order, and a host's own for any other accounts. A password sign-in locks out on failure and reports a locked-out, not-allowed or wrong-password refusal in the failure event while telling the user the same thing; a user needing a second factor is refused and Identity's half-sign-in cookie cleared, since there is no second-factor page. An external sign-in finds the user by login or provisions one as Duende's Identity template does, and — stricter than the template — refuses a user Identity would not let sign in. Logout needed nothing: it ends the session, which is Identity's cookie. All driven through the real pages 0.175.0 — the pages in either order. AddIdentityServerUi registers its test-user IUserSignInService by a factory, so registered before AddAspNetIdentity (or a host's own) it is never built and ValidateOnBuild no longer refuses a Development host for want of an ITestUserStore; with no accounts at all the first page names what to register. Ours: Duende's pages are templates |
| Licensing | ✅ | — deliberate non-goal, and the reason this server exists |
10. Configuration surface
Duende's IdentityServerOptions exposes 37 top-level properties and option groups. Ours exposes
26: seventeen groups — Endpoints, Discovery, KeyManagement, UserInteraction,
PushedAuthorization, Cors, DeviceFlow, Ciba, BackChannelLogout, MutualTls, Events, DPoP,
InputLengths, Csp, Validation, PersistentGrants, Authentication —
plus IssuerUri and LowerCaseIssuerUri, and the seven scalars below, which are implemented rather
than absent. This number has now been wrong twice in the same direction: it said twelve while listing
thirteen after AccessTokenJwtType and EmitStaticAudienceClaim shipped, and thirteen after
EventsOptions shipped in 0.12.0. A count nothing derives is a count that drifts — so it is worth saying
plainly that Ciba moving off the absent list in 0.27.0 was the third time this number had to be
rewritten by hand, InputLengths in 0.29.0 the fourth, Csp in 0.30.0 the fifth, Validation in
0.39.0 the sixth, four scalars at once in 0.50.0 the seventh, and the two groups below in 0.64.0 the
eighth.
Absent groups: Caching, Diagnostics, DynamicProviders, Logging, ServerSideSessions.
(Caching re-checked 0.140.0, when the client and resource store caches arrived: their durations are arguments of
AddClientStoreCache<T> and AddResourceStoreCache<T>, with Duende's 15 minutes as the default, for the same reason.)
Three of those are absent by choice, and the choice is the same one each time — an option is worth
having only where the behaviour it names exists to be changed:
ServerSideSessions— the feature shipped in 0.12.0 behind an opt-in registration,AddServerSideSessions(), with no group in front of it. A host either registers the store or does not.Caching— 0.49.0 put the CORS cache duration where the cache is switched on,AddCorsPolicyCache(duration), for the same reason. Duende carries it asCachingOptions.CorsExpiration.Logging— Duende's group is three filters naming the request parameters to redact before a request is logged. This server never logs a request. Every log statement here is a source-generated[LoggerMessage]naming its fields; there is no dump of a form or a query string to filter. A group of redaction filters over nothing is precisely the shape §1e catalogues, so it stays absent until something logs a whole request — at which point the filters and the logging arrive together.
Diagnostics is absent as a gap, but a small one: it configures Duende's periodic diagnostic-summary
service, and neither the service nor the group is here. Re-measured 0.150.0 (release 22): its two members are read
only by DiagnosticHostedService and DiagnosticSummary, so the options without the service would be §1e's shape;
moved with the service to re-measure row 31. Declined 0.158.0 (decided 2026-10-14): nothing would read
LogFrequency or ChunkSize, and there is no diagnostic summary service; it stays absent, recorded.
Absent scalars worth calling out because a host may reasonably expect them: EmitStateHash and
LogoutTokenJwtType. Both are absent for a reason rather than pending, which is the distinction
note 19 exists for:
EmitStateHashtoggles thes_hashclaim on an identity token. Nothing here computess_hash, so the option would switch a claim on and emit nothing — the option arrives with the claim or not at all.LogoutTokenJwtTypeis settled in code, atDefaultTokenCreationService: OpenID Connect Back-Channel Logout 1.0 §2.4 fixes a logout token'stypatlogout+jwtand entitles a relying party to reject anything else.AccessTokenJwtTypeis configurable because RFC 9068's media type is the one a resource server may reasonably refuse; its sibling is not the same question. Taken 0.150.0 (re-measure release 22) all the same, as Duende's: the option,logout+jwtby default, read byDefaultTokenCreationServicefor a logout token — for the relying party that predates the explicit type. Ours: empty writes the handler'sJWT, where Duende's writes notyp.
StrictJarValidation and StrictClientAssertionAudienceValidation are absent because the strict
behaviour is the only behaviour here — see §6. Corrected 0.124.0 for the first of the two: Duende's
StrictJarValidation is not about the audience, which both servers always check, but about the
oauth-authz-req+jwt type and media type; it is taken, at Duende's default of off.
0.64.0 took the last two groups, by the same rule, and one of them is a security finding rather than a knob.
PersistentGrantsearns exactly one member,DataProtectData, and it defaults to on — which makes it the only member this item has added that changes what a host gets without being set. A persisted grant'sDatais the serialized grant itself: for a refresh token, a reference token or an authorization code, the whole claim set the subject was issued, every claim a profile service added included. Four stores wrote it as plaintext JSON, so the grant table held, in the clear, the identity of everybody with a live session — whileDataProtectionKeyProtectorhas protected the signing keys in that same database since key management shipped, with a flag on the row so a store written before the change still reads. Note 11 at storage scale, and the half that got the rule is the half whose row had somewhere to put a flag.Two decisions inside it are worth more than the member. The protection is a decorator over
IPersistedGrantStore, not a change to the four stores that write the column, becauseDefaultPersistedGrantServicetakes the store directly and deserializesDataitself — a fifth reader the four do not sit in front of. Protecting in the writers would have handed it a payload it cannot read, and it catchesJsonExceptionand answers with no scopes, so an account page would have told people they had consented to nothing, in silence. And the wrapper is applied at each of the three points a grant store is selected — the default,AddPersistedGrantStore<T>(), andAddDapperStores— because the last two register over whateverAddIdentityServer()left, so a wrapper applied once would have protected the in-memory store, which holds nothing worth stealing, and left the database store in the clear. The first draft did exactly that, and the test written for it passed, because it registered its store beforeAddIdentityServer(), the one route the naive wiring covers. Reading the two replacement paths is what caught it; see note 35.Reading never consults the option — only writing does — so a table holding both shapes at once, the ordinary state during an upgrade and after a host changes its mind, reads correctly in both directions. The fallback discriminates on data protection's own format check rather than on a guess.
Authenticationearns nine of Duende's ten as of 0.107.0 —CheckSessionCookieNameandCheckSessionCookieDomain(0.64.0),CoordinateClientLifetimesWithUserSession(0.99.0),CookieLifetime,CookieSlidingExpirationandCookieSameSiteMode(0.101.0), andRequireCspFrameSrcForSignoutandRequireAuthenticatedUserForSignOutMessage(0.106.0), andCookieAuthenticationScheme(0.107.0). This line said two through 0.100.0, a release after 0.99.0 had made it three: another count that nothing derives.The three cookie options reverse a decision this item used to give as its clearest example. Until 0.101.0 they were absent by choice: the cookie's lifetime, sliding and
SameSiteareCookieAuthenticationOptions, which ASP.NET Core owns, and mirroring them looked like a second source of truth. Two things were wrong with that. First, the 1.0 rule: Duende has the same arrangement — its options are applied to the same namedCookieAuthenticationOptions— so absence was not a different design, only a missing one. Second, and the reason it mattered, the option is not only a cookie setting.CookieSlidingExpirationalso governs whether a coordinating client's token renews the server-side ticket and marks it (idsrv.renew) so the browser's cookie is reissued; that behaviour had no setting inCookieAuthenticationOptionsto reach, and 0.99.0 shipped coordination sliding the row alone for exactly that reason. The defaults were the other quiet difference: this server hard-coded eight hours, sliding andLax, where Duende's are ten hours, not sliding andNone— measured from 8.0.1'sConfigureInternalCookieOptions, and now adopted, a behaviour change a host relying on sliding has to opt back out of. What survives of the old reasoning is the precedence: the options are applied as configuration of the scheme, so a host's ownConfigure<CookieAuthenticationOptions>afterAddIdentityServer()still wins, and the test that says so is kept.0.106.0 measured the three recorded as unmeasured, and the first measurement found a defect, not a gap.
RequireCspFrameSrcForSignout(true) is Duende's policy for the page that loads front-channel logout iframes: aframe-srcof their origins, or no policy when off. This server's signed-out page carried the interactive-page policy unchanged,default-src 'none'with noframe-src— so a browser blocked every front-channel iframe, and front-channel logout had notified no client since it shipped. The tests read the HTML, which was right; a probe of the response header found it. The page now sets its own policy, and its iframes are written as Duende's are,allow=""and no sandbox.RequireAuthenticatedUserForSignOutMessage(false) had the opposite surprise: this server had always behaved as if it were true —CreateLogoutContextAsyncmade no context without a signed-in subject, so an anonymous request's verified return was dropped — and Duende's default was adopted with that behaviour change known. The red's prediction missed it: a control predicted green failed, and the option's own test passed for nothing.0.107.0 took
CookieAuthenticationScheme, measured from Duende'sGetCookieAuthenticationSchemeAsync, its readers inDefaultUserSessionandIdentityServerAuthenticationService, its startup check, and the server-side session ticket store's configure-time scheme. One resolver — the option, else the host's default authenticate scheme, elseidsrvwhen registered, where Duende would refuse (the user's call: no host that works today breaks) — now names the session at every site: the user session, sign-out, the federated sign-out wrapper, the authorize and SAML sign-on endpoints, the UI's sign-in and provider list, and the ticket store. Until thenidsrvwas hard-coded at most of them and a few read the default scheme first, so a host whose default was notidsrvhad a session split between two cookies. The resolver is public here (HttpContext.GetCookieAuthenticationSchemeAsync(), in Duende'sAuthenticationManagerExtensions), internal there: the UI and SAML packages are separate assemblies. Like Duende's, the session scheme is written to, so it must be one that can sign in — which three of this repository's own test fixtures were not.The one not taken:
CheckSessionCookieSameSiteMode, declined below with its reason.CheckSessionCookieNameandCheckSessionCookieDomainare the other case:SessionStateServicewrites that cookie withResponse.Cookies.Append, so no options system reached it at all, and its name was a constant read at four sites — one of them inside the JavaScript the check-session iframe runs. Two identity servers on one registrable domain wrote the same cookie and each read the other's browser state. The second half is the one that fails silently: the page is served under a Content-Security-Policy naming the hash of its own script, so a name substituted into the page without re-hashing it yields a policy that blocks the script it exists to permit — an iframe that loads, runs nothing and answers no poll, which a relying party cannot tell apart from a session that has not changed. The page and its hash are built together from the one string that is returned.One member was drafted and dropped because checking it showed the remark false.
CheckSessionCookieSameSiteModereads like the escape for developing over plain HTTP, and it is not: the cookie is writtenSecureunconditionally, so the browser drops it over HTTP whateverSameSitesays. Making that work would take relaxingSecureas well, which this server is not going to offer. The honest sentence is that check-session does not work over plain HTTP; a single option would have read like a fix for it. Note 19's rule — a deferral names a reason, not a destination — applied to a member rather than to a capability.
0.50.0 took four scalars, and each was chosen the same way: behaviour that already existed and that no host could reach. Not one of them is a property added to move this count — which is the pressure this item is under, and the reason it was taken last of the large ones.
SupportedRequestObjectSigningAlgorithmsandSupportedClientAssertionSigningAlgorithms. NeitherRequestObjectValidatornorPrivateKeyJwtSecretValidatorsetValidAlgorithms, so the algorithm a client could sign under was whatever its registered key happened to support — a set inferred from a registration rather than stated, invisible to an operator, and named nowhere in discovery. A client usingprivate_key_jwtdiscovered the acceptable algorithm by being refused. Both lists default to the nine asymmetric algorithms that already worked, so introducing them refuses nothing, and both are now advertised asrequest_object_signing_alg_values_supportedandtoken_endpoint_auth_signing_alg_values_supported— the ruleDPoPOptions.SupportedSigningAlgorithmsalready stated, applied to the two doors that had it and said nothing.JwtValidationClockSkew, defaulting to zero, and this is the one worth reading twice.DefaultTokenValidatorset noClockSkew, so IdentityModel's default of five minutes was in force: every JWT access token this server issued went on being accepted for five minutes past theexpires_inthe token response had published for it, at UserInfo, at introspection and at a local API. A skew exists so two clocks that disagree do not reject each other, and the token being read there was signed by this same process. The two places this server reads a JWT somebody else signed keep five minutes deliberately, and say so at the line. See method note 22 below.EmitScopesAsSpaceDelimitedStringInJwt, defaulting to true. RFC 9068 §2.2.3 says thescopeclaim of anat+jwttoken is a JSON string holding a space-separated list, andAccessTokenJwtTypehas declaredat+jwtby default since it shipped. What it replaced was not "an array": the payload builder collapses a single-valued claim group to a scalar, soscopewas a string for a client that asked for one scope and an array for a client that asked for two. A resource server written against the first broke the day a second scope was added to a client — and nothing noticed, becauseJsonWebTokenHandlerturns both forms into the same list ofClaimobjects, so every test in the tree read the parsed claims and passed either way. The reader half went with it: three consumers here — the local API scheme, UserInfo, introspection — comparedClaim.Valueand would have refused a token that genuinely held the required scope. They share oneScopeClaims.From, and the local API hands its endpoint onescopeclaim per scope whatever the wire carried, becauseRequireClaimand a host's own policies compare a single value.
Method note 22, from
JwtValidationClockSkew: a setting that does not exist still has a value — the library's. Every sweep in this document asks about members that exist: read at zero sites, written at zero sites, reachable or not. None of them can ask about aTokenValidationParametersproperty that was never assigned, because there is nothing in the tree to find. The five-minute grace on every access token this server issued was not written anywhere, was not chosen by anyone, and would have survived any query oversrc/. The question that finds this one is asked of the library, not of the code: which defaults am I accepting by not setting anything? Row 11 was a level above a member; this is a level below one.
0.58.0 ran note 22's question across the whole tree, and it came back with one answer at three sites. Asked of everything rather than of one property: the cookie policies are stated at both schemes (
SameSite,SlidingExpiration,ExpireTimeSpan, andSameSiteMode.Nonewhere a check-session frame needs it); the SAML readers setPreserveWhitespace,DtdProcessingandXmlResolver, so neither canonicalisation nor DTD processing is inherited; everyJsonWebTokenHandlereither states aClockSkewor switches lifetime validation off and does its own window; the key cache has an option; and the request-object fetch already namesMaxResponseContentBufferSizein a comment explaining whyResponseHeadersReadis what makes its own 64 KiB cap reachable. Even the OIDC handler's inbound claim remapping is accommodated rather than inherited — the external-login page readssuband falls back toClaimTypes.NameIdentifier, with a comment on why. That is what a clean answer looks like, and it is most of the surface.What nothing stated was
AllowAutoRedirect, at all three clients that make outbound requests, and each of the three guards something that a second hop steps around. The request-object fetch refuses anything that is not absolute HTTPS, for the stated reason that a request object travels unauthenticated, and applies that to the URL the client submitted — so a 302 had the second hop fetched under neither that rule nor any egress policy a host had imposed. Note 11 at the level of a hop. The back-channel logout client treats a non-success status as a failure to notify, in as many words at the call — andHttpClientre-issues a redirected POST as a GET without the body, so the second URL was asked to sign nobody out, answered 200, satisfiedEnsureSuccessStatusCode, and the relying party was recorded as notified. A logout that did not happen, reported as one that did — precisely the stateBackChannelLogoutFailureEventwas added in 0.51.0 to make visible. AndClientCredentialsTokenServiceresolved the unnamed default client, which is everyHttpClientHandlerdefault there is: sinceAuthorizationis stripped only when a redirect leaves the origin, a token endpoint answering 302 to another path on the same host presented the client secret to a URL the host never configured, with a response that does not parse as the only symptom.The most uncomfortable part is that the README already knew. The JAR section warned that a redirect could take an allowed URL to another host and told the host to enforce its policy on the handler rather than on the URL — a true sentence, describing this server's own default, telling somebody else to fix it. Note 19's shape in documentation rather than in code: the warning read as a limitation of the client's position and was in fact a description of an unset property. It has been the release note of its own fix.
Method note 29 (0.58.0): a default is only inherited where nobody could see it being chosen. Note 22 says to ask the library which defaults you are accepting. What 0.58.0 adds is where to ask it: the answer was clean everywhere a value is written next to the thing it configures — an options class, a cookie, an
XmlReaderSettings— and wrong at every point where the configuration lives in one file and the rule it undermines lives in another. All three defects are a registration inAddHostingServicesorAddClientCredentialsTokenManagementsitting a long way from the class whose guard the default defeats, and in each the class's own remarks state the guard confidently. So the fix is stated twice on purpose: the handler policy at the registration, and, for the fetch, the refusal in the class as well — because a rule that only exists as a line of DI configuration is one a reader of the class cannot see, which is the condition this note describes.
EmitIssuerIdentificationResponseParameter came off that list in 0.26.0 and is the one of the three
that defaults on: RFC 9207 §2 says an authorization server should send iss, and OAuth 2.0
requires a client to ignore parameters it does not recognise, so emitting it cannot break a client
that is not looking for it. Turning it off also stops discovery advertising it, because a client told
the parameter is there and then not sent it fails the check that was the point of telling it.
⚠️ AccessTokenJwtType and EmitStaticAudienceClaim were on that list. A host migrating from Duende
that set them would have lost both, and both were assumed to change issued token shape. Only one of
them ever did. Both are now implemented, with Duende's defaults:
EmitStaticAudienceClaimwas a real change. Nothing emitted the{issuer}/resourcesaudience, whileIdentityServerConstants.AccessTokenAudiencestill spelled out the format string it would have used — so the audience looked supported to anyone reading the constants. Duende defaults it tofalse, so a host that set it explicitly was opting into backwards compatibility and did lose it.AccessTokenJwtTypewas not a change, and any claim that thetypheader reverted fromat+jwttoJWTis wrong.DefaultTokenCreationServicehardcodedat+jwtfor access tokens, andat+jwt(RFC 9068) is also Duende's default — so the emitted tokens were byte-identical on this header. What was missing was the ability to change it, which is a much smaller thing than a shape change. It is now an option, still defaulting toat+jwt.
The lesson is the one this document keeps repeating: the absence of an option was read as a change in behaviour, when the behaviour was hardcoded to the same value all along. Only checking the call site tells them apart.
Client properties — counted for the first time in 0.80.0
Nothing above counted them, so no sweep of this document could find one missing. Measured by name against
Duende 8.0.1's Duende.IdentityServer.Storage.xml: 57 documented Client properties, 17 absent here at
0.80.0, 14 at 0.81.0, 13 at 0.82.0, 11 at 0.83.0, 9 at 0.84.0, 7 at 0.85.0, 1 at 0.99.0 — and that one absent on the record, under the implicit non-goal. 57 is a floor, for the reason under What this does not yet cover: an undocumented
property is not in the XML. A host migrating from Duende that sets one of them gets a compile error rather than
silent loss, which is the one mercy of the shape.
What decides each one is whether Duende's own runtime reads it, measured in 0.81.0 by scanning
Duende.IdentityServer.dll's IL for every call to the getter (and the Storage assembly's, for the four with
none):
| Properties | Duende reads it in | |
|---|---|---|
| ✅ 0.85.0 | DPoPClockSkew, DPoPValidationMode |
DPoP proof validation at the token endpoint, with server-provided nonces (§8); at this server's own resource endpoints — userinfo and the local API — since 0.86.0, with the resource's nonce (§9); at PAR since 0.87.0 — see the DPoP row in §6 |
| ✅ 0.84.0 | IncludeJwtId, AllowedIdentityTokenSigningAlgorithms |
access-token creation (jti only when set, access tokens only); identity-token creation (the credential, the hash claims' size, and the token's allowed list). Duende's third reader, code creation, uses the list for s_hash, which is not computed here — see EmitStateHash above |
| ✅ 0.83.0 | DeviceCodeLifetime, UserCodeType |
the device authorization response generator — DeviceCodeLifetime an int? here, falling back to the server-wide DeviceFlow.Lifetime Duende does not have |
| ✅ 0.82.0 | RequireRequestObject |
the authorize validator's client check, so PAR's too, and the backchannel validator — invalid_request at all three endpoints |
| ✅ 0.81.0 | EnableLocalLogin, IdentityProviderRestrictions, UserSsoLifetime |
ProcessLoginAsync (all three); the authorize and backchannel validators (IdentityProviderRestrictions) |
| ✅ 0.99.0 | CoordinateLifetimeWithUserSession, UpdateAccessTokenClaimsOnRefresh |
sign-out and session validation — refresh tokens and access tokens — with AuthenticationOptions.CoordinateClientLifetimesWithUserSession as the default; refresh-token processing, which hands back the access token the refresh token keeps unless the flag is set |
| ✅ 0.99.0, storage | Description, InitiateLoginUri, PairWiseSubjectSalt, Properties |
nothing, here as in Duende — a host's to keep with the registration |
| ⛔ non-goal | AllowAccessTokensViaBrowser |
the authorize validator's response-type check, which gates token-bearing response types — implicit and hybrid, the implicit non-goal; this server accepts response_type=code alone |
0.99.0 took the last of them, and two decisions came with it. UpdateAccessTokenClaimsOnRefresh lands at
Duende's default, false: a refresh hands back the access token the refresh token keeps — per resource indicator,
creation time now and lifetime renewed, jti and claims as issued, the profile service not asked — where every
refresh here had built one afresh, Duende's true with no way to choose false. A host relying on claims being
re-read on refresh opts in per client. CoordinateLifetimeWithUserSession lands additively: Duende's sign-out
removal and its session check at refresh and at access-token validation, with the session slid on use (the row
only — Duende's ticket renewal is gated on CookieSlidingExpiration, off by default and absent here until 0.101.0,
which added it with Duende's renewal of a persistent, refreshable ticket). Duende's
expiry handling is narrower than this server's and is not adopted: there an expired session removes only
coordinating clients' tokens and notifies clients only under ServerSideSessionOptions.ExpiredSessionsTriggerBackchannelLogout
(default false); here an expired or administratively ended session revokes every grant it issued and notifies every
client, as it has since server-side sessions shipped. A deliberate, stricter divergence, chosen over parity.
Found on the way, three gaps in the chain coordination needs, all fixed in 0.99.0. Access tokens carried no
sid — Duende's DefaultTokenService.CreateAccessTokenAsync adds it from the request's session — and nothing
carried the code's session to the token request, so refresh tokens recorded no session either. And a server-side
session this server signed in recorded no session id on its row: ServerSideTicketStore read only a sid claim,
and this server's own sign-in keeps the id in the cookie's properties. Between them, the session-end revocation
above — filtered by subject and session — had been finding no refresh token and, for this server's own sessions,
returning before it looked. Found then and fixed in 0.100.0: identity tokens carried no sid either, where Duende's
CreateIdentityTokenAsync adds it whenever the request has a session id, whatever the client's logout settings.
They now do, from the code exchange and from a refresh.
Found on the way, while decoding ProcessLoginAsync, and fixed in 0.95.0: before the three client checks it
re-prompts when the session's idp differs from the request's idp: hint, and — under
IdentityServerOptions.ValidateTenantOnAuthorization, default false — when its tenant claim differs from the
request's tenant:. Both now here, with Duende's option, and with the authorize validator's RemoveIdP respected:
a hint outside the client's IdentityProviderRestrictions asks for nothing, so it cannot loop.
11. SAML
Duende documents 296 SAML types across Saml.* and Internal.Saml.Sp.*; about 90 of those are
generated *.Log types, leaving ~206 capability-bearing. We have 53 public types across 23 files.
As §11 previously said, that ratio is not a comparison. Below is the comparison.
Method, and why the ratio was misleading in both directions. Duende's side is read from the
shipped XML as everywhere else, and the same warning applies harder here than anywhere: a property
on a model proves nothing about whether the value is honoured. Two examples from writing this
section — a keyword search for "ECP" matched KeyAlgorithmOids.EcPublicKey, which is elliptic curve
and has nothing to do with Enhanced Client/Proxy; and SSODescriptor.ArtifactResolutionServices is
a metadata property describing somebody else's artifact endpoints, not an artifact binding.
Neither server implements artifact resolution. Our side is read from call sites.
A third example, from our own side and caught only by going back to fix it: a grep for read sites
that excluded lines matching SamlConstants also excluded the comparison
response.Version != SamlConstants.Version — the exact line that disproved the finding being made.
Every claim below was re-derived without filters afterwards; three survived unchanged and one
shrank. A filter narrow enough to be convenient is narrow enough to remove the disproof.
Where the two servers actually differ
| Capability | Duende | Here | Note |
|---|---|---|---|
| HTTP-Redirect binding | ✅ | ✅ | Both directions. Detached query-string signature, signed and verified |
| HTTP-POST binding | ✅ | ✅ | Both directions — but the service provider sent nothing by POST until 0.109.0. "Both directions" was true of what it received; every message it sent went by HTTP-Redirect, where Duende's SP can bind its AuthnRequest either way. SamlSpOptions.IdpSingleSignOnBinding now does too: a posted request signed inside the XML, in a form the browser submits |
HTTP-Artifact binding / ArtifactResolve |
⬜ | ⬜ | Duende has the metadata property only. Absent on both sides |
| SOAP / PAOS / ECP | ⬜ | ⬜ | Duende has two SOAP namespace constants on the SP side and no implementation |
| Attribute query | ⬜ | ⬜ | Neither |
| Attribute release | ✅ | ✅ 0.141.0 | Measured from Duende 8.0.1's Saml2SSoResponseGenerator (CreateAttributesAsync, MapClaimsToAttributes), DefaultSamlResourceResolver, SamlOptions.DefaultClaimMappings and the attribute writer. The profile service is asked at both doors for the claim types the provider's AllowedScopes resolve to — enabled identity resources only, narrowed by RequestedClaimTypes; no scopes, an unresolved scope or a claim type outside them is Service provider configuration error (the request validator's last check, Responder on the error page; after sign-in at the IdP-initiated door), and a registration with no scopes is refused where it is read (at least one allowed scope is required). Claims are named by the provider's ClaimMappings, else the server's three (name, email, role → WS-Federation URIs, role without claims/ as Duende ships it), not merged, an unmapped type as itself; ProfileDataRequestContext.SamlAttributes first, a mapped claim of the same name joining one; NameFormat and FriendlyName written only when set. Until 0.141.0 every provider was sent the session's name and email claims under those names and the profile service was not asked — breaking: attribute names change and every registration needs a scope. Ours: one SamlAttribute (moved to Core, forwarded) where Duende has two of one shape; a validator or IdP-initiated endpoint built without an ISamlResourceResolver resolves nothing; the SP side reads the two new members too |
| Web SSO, SP-initiated | ✅ | ✅ | 0.70.0 signs the request. SamlSpOptions.SigningCertificate signs all three messages this SP sends — the AuthnRequest, the LogoutRequest and the LogoutResponse answering the provider's — with the detached query-string signature the redirect binding takes. 0.60.0 recorded the gap as the unsigned LogoutResponse and the metadata flag, which is two of the three; the request in the middle went unnamed. What made it easy to miss is that the machinery was already here: SamlRedirectBinding.BuildSignedRequestQuery had thirty callers and every one was a fixture signing messages into this server as a partner would, so a well-tested signing path had never signed anything this server sent — note 18 at the scale of a capability, and release gate (a) missing it because the member is reached by name from tests rather than being unreferenced. BuildResponseQuery had no signed twin either, so the only parameter name a signed redirect-bound message could travel under was SAMLRequest; the twin shares one Sign helper with the request builder so the two directions cannot drift 0.133.0 — what an inbound request must be (re-measure release 7), Duende 8.0.1's AuthnRequestValidator and LogoutRequestValidator, measured: AuthnRequests signed unless the provider opts out (SamlOptions.WantAuthnRequestsSigned, default true, advertised in metadata; SamlServiceProvider.RequireSignedAuthnRequests); IssueInstant within the provider's ClockSkew ahead and RequestMaxAge behind (or SamlOptions.ClockSkew/DefaultRequestMaxAge), a resumed request aged to its arrival; Destination, when named, this server's SSO URL ignoring case, and required on a signed request; a signed LogoutRequest's version, destination and NotOnOrAfter likewise; MaxMessageSize on the POST decode, the redirect inflate (bounded while it inflates) and the XML load, MaxRelayStateLength on RelayState; IAuthnRequestValidator with protected virtual steps; the configuration validator's lifetime checks. Where ours differs: an oversized or malformed message is the error page and an event, where Duende's is a 500; an unsigned LogoutRequest still reaches the logout prompt, where Duende refuses it; a LogoutRequest's NameID and SessionIndex are not yet matched to the session. Not yet: ILogoutRequestValidator (the checks are still the endpoint's) and the read-error inspectors (the reader throws on its first error rather than collecting them) — both left for re-measure release 20, then 26, and ✅ 0.153.0. 0.134.0 — what an assertion names the user by (re-measure release 8), Duende 8.0.1's DefaultSamlNameIdGenerator, ValidateNameIdFormat and ResolveAuthnContextClassRef, measured: ISamlNameIdGenerator issues emailAddress from the provider's EmailNameIdClaimType or SamlOptions.EmailNameIdClaimType (email) and every other format from sub, a user without the claim refused with InvalidNameIDPolicy; SupportedNameIdFormats (emailAddress, unspecified) checked at the request and listed in metadata; SamlServiceProvider.NameIdFormat renamed DefaultNameIdFormat; AuthnContextClassRef from acr, else the first mapped amr, through the provider's AuthnContextMappings or DefaultAuthnContextMappings (pwd, external), unspecified when none maps; RequestedAuthnContext read and handed to the login page with the tenant: convention, not enforced. Corrected: the plan said Duende's generator computes a per-provider persistent and a transient identifier; it labels sub with either, and so does this one — a pairwise identifier is still a host's own generator. Where ours differs: ClaimTypes.NameIdentifier is still accepted when sub is absent; SamlAuthenticationContext.PromptModes stays empty where Duende maps ForceAuthn/IsPassive onto it, for the reason its remarks give; IdP-initiated sign-on records the NameID it issued, where Duende records sub. Not yet: a login page cannot deny a SAML sign-in (DenyAuthenticationAsync refuses a SAML context), so Duende's NoAuthnContext for unmet requirements is unreachable — left for re-measure release 12, moved to release 24 and done in 0.151.0 (row Sign-in denial below) 0.175.0 — how the provider says the user signed in. SamlMessageReader reads AuthnStatement/AuthnContext/AuthnContextClassRef (SAML Core §2.7.2.2; empty when none is stated) — until then a parsed assertion always carried PasswordProtectedTransport — and SamlSpHandler adds it as ClaimTypes.AuthenticationMethod and the AuthnInstant as ClaimTypes.AuthenticationInstant, as Microsoft.IdentityModel's Saml2SecurityTokenHandler.ProcessAuthenticationStatement does under Duende 8.0.1's SP, measured; neither becomes the session's acr/amr, as in Duende's template |
| IdP-initiated (unsolicited) SSO | ✅ | ✅ 0.59.0, both directions | As IdP: saml/sso/initiate?sp={entityId}, a route of its own, off until SamlOptions.EnableIdpInitiatedSignOn and answered by the middleware's fall-through until then, so an untaken capability looks like a route this server does not serve. As SP: SamlSpOptions.AllowIdpInitiatedSignOn, also off, landing on IdpInitiatedReturnPath. 0.143.0: each provider must also say AllowIdpInitiated (Duende's, false by default; breaking), else idp_initiated_not_allowed with Duende's message, and the response goes to the provider's default assertion consumer service. The response names no request at both places InResponseTo can appear, and a null expectation means "must name no request" rather than "do not check" — accepting unsolicited assertions by ignoring the correlation would accept a captured solicited one. 0.178.0: as SP, the sign-in is recorded against the receiving scheme and each assertion is accepted once (rows below) |
| Single logout | ✅ | ✅ a sequence rather than a message as of 0.61.0 | Both carry SessionIndex. As IdP: receives a LogoutRequest, ends the session, asks every other service provider that session reached to end its own — one browser POST per participant, sequenced through ISamlLogoutSessionStore — and answers the one that asked last, naming its request (0.61.0). As SP: sends a LogoutRequest upstream (0.42.0), and acts on one arriving from the identity provider (0.60.0). This row read a flat ✅ for four releases while one direction did nothing, and then 0.60.0's own correction of it asserted the propagation that 0.61.0 actually built — see below. And "both carry SessionIndex" was true of one side only until 0.129.0: as IdP the endpoint read it and never sent one, every participant was asked with the requester's NameID, a new index was made up per assertion and thrown away, the participants' answers' status was never read, and the requester was always told Success. As of 0.129.0, Duende's, measured: the session keeps each provider's NameID and SessionIndex (one index per provider per session), each participant is asked about its own, and the requester is answered PartialLogout under Success when any was passed over or failed (SamlLogoutSession.SkippedSpCount). And the sequence is no longer the only way in: the logout page and end_session reach the providers too — see the front-channel row |
| Metadata publish + consume | ✅ | ✅ | Reader and writer both sides. As of 0.144.0 the IdP document is Duende's, measured from its Saml2MetadataResponseGenerator and result writer: written by ISamlMetadataResponseGenerator (the default SamlMetadataResponseGenerator open at Duende's one protected virtual step, GenerateEntityDescriptorAsync), with an ID, cacheDuration and validUntil from SamlOptions.Metadata (SamlMetadataOptions: 12 hours, 5 days), one signing KeyDescriptor per current key, and text/xml to a browser. Until then it carried one key and said nothing about how long to keep it. Ours: the ID is _ and a GUID; the endpoint paths are still the fixed /saml/sso and /saml/slo (release 20) |
| IdP signing key | ✅ the server's key material | ✅ 0.144.0 | ISamlSigningService, Duende's, measured from its SamlSigningService and RsaCertificateFactory: the credential IKeyMaterialService signs tokens with — an X.509 key as its certificate (refused without its private key), an RSA key that is a current managed key wrapped in a self-signed certificate (CN= the entity id, critical digital-signature usage, valid from the key's creation for its retirement age, serial from the key id, cached per key id, ten at most), anything else refused — so a key rotation reaches every assertion, response, logout message and the metadata, which publishes every current RSA and X.509 key and no EC key. Until 0.144.0 SAML signed with SamlOptions.SigningCertificate alone, required and never rotated. Ours: that option, when set, still signs and is the one key published (Duende has no such option); the key manager is asked only while automatic key management is on 0.175.0, a manually registered RSA key, measured again: Duende's DefaultKeyMaterialService answers a registered credential before the managed keys, so its SamlSigningService refuses a manual RSA key ("Cannot auto-wrap…") with automatic key management on or off; here it is refused only with management off, the managed key signing while it is on. Not a defect at parity and none against the spec (SAML Core and Metadata require no certificate, and a manual key has no creation date to make one from); a certificate, or SamlOptions.SigningCertificate, is the way to sign with your own key |
| Service provider registration | ✅ | ✅ 0.143.0 | Measured from Duende 8.0.1's SamlServiceProvider, AuthnRequestValidator.ValidateAcsUrl, ServiceProviderEntityResolver, Saml2SSoResponseGenerator, DefaultIdpInitiatedSsoService and DefaultSamlServiceProviderConfigurationValidator. Several assertion consumer services (IndexedEndpoint, each HTTP-POST) chosen by the request's URL — compared as Uri.AbsoluteUri — or AssertionConsumerServiceIndex, else the default, else the first, with Duende's three refusals; several logout services (SamlEndpointType, GetSingleLogoutServiceEndpoint); several certificates with a KeyUse, any signing one verifying at sign-on and all three logout checks (rollover); Enabled, Description, AssertionLifetime, AllowIdpInitiated, SigningBehavior with SamlOptions.DefaultSigningBehavior (SignAssertion); the validator's binding and AssertionLifetime must be positive checks in Duende's order. Ours: the single-URL and single-certificate members kept as shorthands over the collections; a failure response signed unless DoNotSign (Duende signs one only under SignResponse); assertions encrypted to the first encryption certificate (Duende encrypts none); /saml/slo answers on HTTP-POST where Duende's logout is HTTP-Redirect only; ProtocolType saml (decision 3 open); every endpoint URL held to the https rule; init members. Not taken: RequireSignedLogoutResponses, AllowedSignatureAlgorithms (release 28) |
| Endpoint paths, bindings and switches | ✅ SamlEndpointOptions, five EnableSaml*Endpoint |
✅ 0.145.0 | Measured from Duende 8.0.1's SamlEndpointOptions, AddSaml, EndpointOptionsExtensions.IsEndpointEnabled and metadata generator. SamlOptions.Endpoints: the sign-on, callback and logout paths and both binding lists, read per request by the router, the Destination checks, the return URL and the metadata (Duende reads its paths once, at AddSaml); defaults this server's own (/saml/sso, /saml/sso/callback, /saml/slo), the IdP-initiated door following at {sso}/initiate. A sign-on resumes at the callback under StateIdParameterName (samlStateId), where until 0.145.0 it resumed at the sign-on path under resume. Four switches, off by default and on from AddSaml, as Duende's; the sign-on one closes the IdP-initiated door too. Ours: a binding not listed is not served, where Duende only leaves it out of metadata; no EnableSamlLogoutCallbackEndpoint/SingleLogoutCallbackPath — single logout here is a browser sequence answered at the service path, with no callback |
| Inbound seams: logout request validator, read-error inspectors | ✅ ILogoutRequestValidator/LogoutRequestValidator, SamlOptions.*ErrorInspector, ReadErrorInspectorContext<T> |
✅ 0.153.0 | Measured from Duende 8.0.1's LogoutRequestValidator, SingleLogoutServiceEndpoint, SamlXmlReader.CallErrorInspector and XmlTraverser.ThrowOnErrors (re-measure release 26). SamlLogoutRequestValidator, registered by AddSaml: the single logout endpoint's checks as Duende's six protected virtual steps, in Duende's order, run by a non-virtual ValidateAsync; every check and code as before. HasTrustedSignature is computed from the message as received and its provider, never set, and an untrusted request ends no session by itself — out of reach of a step override, a host's validator or an inspector (Duende's trust is a settable TrustLevel its reader sets before the inspector runs). The reader collects its errors (Error, ErrorReason); the three inspectors are asked only when there is one, the read failing on any left standing. Ours: ValidateSessionAsync passes by default (Duende's NameID/SessionIndex match is release 34, opt-in at 0.159.0 — the next row; SessionFound false is answered as Duende's); an unsigned request from a provider without a certificate still reaches the logout page and a provider with no logout endpoint is still signed out, where Duende refuses both; the inspector's Data is read back; six of twelve ErrorReasons. Found: an unreadable IssueInstant/NotOnOrAfter was a 500 at sign-on and single logout, now a refusal; the SP-side logout handler missed the reader's exception (a 500 for a request with no ID), now a 400 |
| Logout request matched to the session | ✅ LogoutRequestValidator.ValidateSessionAsync (always on), ValidatedLogoutRequest.Subject |
✅ 0.159.0, opt-in | Measured from Duende 8.0.1's ValidateSessionAsync and its three Log methods (re-measure release 34). Behind SamlOptions.MatchLogoutRequestToSession (false) and SamlServiceProvider.MatchLogoutRequestToSession (null takes the option), read inside the virtual step so an override replaces it, as Duende's: for a signed-in user (Subject, which the endpoint reads from the session's scheme), no NameID refused (name_id_required), a NameID matching none of the provider's sessions refused (name_id_mismatch, ordinal), no session with the provider or an unmatched SessionIndex answered Success with nothing ended (SessionFound false); Duende's log messages, names and levels. A new SamlLogoutRequestValidator overload takes IUserSession and a logger — the one AddSaml's registration and the endpoint's validator-less constructor use. Release 26's guardrails kept: the step is reached only by a verified request and can only refuse or leave a session running. Ours, decided 2026-10-14: off by default, where Duende always checks, since each outcome changes what a verified request does today (it ends the session); this server's codes rather than the Requester status; with the switch on, a validator built through the old constructor throws rather than pass unmatched |
| Inbound signature policy | ✅ SamlOptions.RequireSignedLogoutResponses, SamlServiceProvider.RequireSignedLogoutResponses, AllowedSignatureAlgorithms |
✅ 0.154.0 | Measured from Duende 8.0.1's SingleLogoutServiceEndpoint.ValidateResponseTrustAsync, ServiceProviderEntityResolver, SamlXmlReader, SignedXmlHelper.VerifySignatureCore and HttpRedirectBinding.ValidateRedirectSignatureAsync (re-measure release 28). RequireSignedLogoutResponses: the option true, the provider's null taking it, read for a LogoutResponse only — here at both places one arrives, a sequence's answer and the signed-out page's iframe; false lets an answer with no signature through from a registered provider, with or without a certificate, and one carrying a signature is still held to it. AllowedSignatureAlgorithms: empty by default; read for the provider's AuthnRequests, LogoutRequests and LogoutResponses on both bindings, as Duende's single list — an enveloped signature's signature and digest methods each named, a redirect signature's SigAlg named — and inside HasTrustedSignature, so no validator step trusts a method the provider does not allow. Ours: the list only narrows the pinned methods (Duende's replaces its defaults, and so can admit SHA-1); an unsigned answer from an unregistered issuer is refused whatever the option, where Duende falls back to it; a refusal stays a 400 with a failure event, where Duende's is a 200 and a log line |
| State lifetimes | ✅ SigninStateLifetime, LogoutSessionLifetime |
✅ 0.145.0 | 15 and 5 minutes, as Duende's, written onto each stored state as ExpiresAt (Duende's ExpiresAtUtc) so a host's own store can honour it. Changed: the in-memory sign-in state store kept every state 20 minutes |
| Login page context | ✅ SamlAuthenticationContext |
✅ 0.145.0 for LoginHint, IdP, RelayState, IsIdpInitiated |
LoginHint from the request's Subject/NameID; IdP from an IDPList naming exactly one provider, Duende's rule — the bundled login page acts on it as on idp:. Ours: Scoping is accepted, where Duende's default validator refuses it and its hint needs an override; PromptModes stays empty (recorded at 0.134.0); StateId ✅ 0.151.0 — this server's opaque handle where Duende's is a Guid, read by the denial |
| Sign-in denial | ✅ DenyAuthenticationAsync for a SamlAuthenticationContext, MapDenialToSamlStatus |
✅ 0.151.0 | Measured from Duende 8.0.1's DefaultIdentityServerInteractionService.DenyAuthenticationAsync and SingleSignOnCallbackEndpoint.HandleDenialAsync/MapDenialToSamlStatus. The denial is written onto the stored sign-in state (SamlSignInState.DenialError, DenialErrorDescription) the context's StateId names and stored back; a state no longer there is logged at Warning and left. The sign-on endpoint reads it back before asking for a session and answers the provider with a signed failure response: Responder, and NoPassive (InteractionRequired), RequestDenied (ConsentRequired), NoAuthnContext (UnmetAuthenticationRequirements), else AuthnFailed — AccessDenied included; no StatusMessage (Duende's IsSafeError is always true and its error response writes none); SamlSignOnFailureEvent with Duende's access_denied (<error>) and the description as its message. Ours: the interaction service reaches the SAML state through ISamlSignInDenialService, SAML being its own package; each door answers its own denial (Duende's IdP-initiated sign-ons also resume at its callback), the IdP-initiated one with no InResponseTo, and only after the stored request is re-checked; StateId a string handle; a context neither OIDC nor SAML is still ArgumentException (Duende: InvalidOperationException); the failure is signed unless DoNotSign (0.143.0); DefaultIdentityServerInteractionService gains an optional last constructor parameter (binary break only). The response generator that will own the error half is release 25 |
| Response generator | ✅ ISaml2SsoResponseGenerator, Saml2SSoResponseGenerator, Saml2InteractionResponse |
✅ 0.152.0 | Measured from Duende 8.0.1's Saml2SSoResponseGenerator (IL), its callers (SingleSignOnServiceEndpoint, SingleSignOnCallbackEndpoint.HandleDenialAsync, DefaultIdpInitiatedSsoService) and Saml2SsoInteractionResponseGenerator. Both doors answer through the generator: CreateResponse once the user is signed in, CreateErrorResponse for a passive request nobody can answer (Duende's "Cannot passively authenticate user" messages) and for a declined sign-in; AddSaml registers the default transient (TryAdd). The open default's protected virtual steps in Duende's order — CreateSubjectNameIdAsync, CreateSaml2ResponseAsync, CreateAssertionAsync, CreateSubject, CreateConditions, CreateAuthnStatement, CreateAttributesAsync, MapClaimsToAttributes — and IsSafeError, true for every error as Duende's; the error response carries the status codes alone (no StatusMessage), so no message reaches the provider; a non-error is ArgumentException with Duende's message. Ours: the run methods hold, after the steps, the response's issuer, Destination, InResponseTo and success status, the assertion's issuer, NameID, Recipient, InResponseTo, audience and SessionIndex, and a lifetime a step may shorten but not lengthen (InvalidOperationException), where Duende's steps may change each; Saml2FrontChannelResult carries the signed XML (Duende's an OutboundSaml2Message) and the NameID refusal's status; this server's models and DateTimeOffset in the steps; an error response cannot say Success; the ACS binding is the configuration validator's check, not repeated. The endpoints' 0.151.0 constructors kept, a new one each |
| Interaction and logout response generators | ✅ ISaml2SsoInteractionResponseGenerator/Saml2SsoInteractionResponseGenerator, ISaml2SloResponseGenerator/Saml2SloResponseGenerator |
✅ 0.163.0 | Measured from Duende 8.0.1's two generators (IL), their callers (SingleSignOnServiceEndpoint; SingleLogoutServiceEndpoint, SingleLogoutCallbackEndpoint, SpLogoutCompletionEndpoint) and registrations (re-measure row 39). The sign-on service asks the interaction generator what an arriving request needs — the default the endpoint's decision, unchanged: nobody signed in or inactive, or ForceAuthn, to the login page, IsPassive making each Duende's NoPassive error, with its messages and logs; the callback checks for itself, as Duende's. The single logout endpoint answers the provider that asked through the logout generator when the sequence finishes — CreateSuccessResponse, or CreatePartialLogoutResponse when anyone was skipped or failed — open at Duende's CreateResponseAsync step. ValidatedLogoutRequest gains RelayState and IssuerName (Duende's Saml2IdpEntityId). Held where no override reaches: no assertion with nobody signed in or for ForceAuthn, no login page for IsPassive (the endpoint); the LogoutResponse's issuer, destination, correlation and status, and its signature (the run methods). Ours: the step returns the response unsigned; the destination is the HTTP-POST logout endpoint without Duende's fallback to the request's issuer; the issuer is the request's IssuerName; one message kept for an inactive user on a forced passive request. Not breaking: constructors kept, overloads added |
| Encrypted assertions | ✅ | ✅ both directions as of 0.55.0 | Both open an <EncryptedAssertion>, and both publish the encryption key in metadata so a partner can find it. The IdP half — encrypting an outbound assertion, and the metadata reader that stopped skipping a partner's encryption KeyDescriptor — shipped in 0.55.0; this row said otherwise until 0.59.0 noticed, which is recorded below |
| SP metadata | ✅ | ✅ | Published at SamlSpOptions.MetadataPath. Every claim in it is one this server keeps — WantAssertionsSigned true because the validator requires it, the encryption algorithms read from the same list the decryption path enforces, and, as of 0.70.0, AuthnRequestsSigned follows SamlSpOptions.SigningCertificate with the certificate published as a signing KeyDescriptor beside it. The flag and the key are written from one argument because they are one fact told twice: the flag alone tells a partner to expect a signature it cannot verify, the key alone invites it to ignore one it could have checked. It read AuthnRequestsSigned false until 0.70.0, and truthfully — there was no SP signing key. A certificate with no private key is refused at startup rather than at the first sign-on, because the registration one line above it in the documented example loads a certificate that correctly has none |
ForceAuthn |
✅ | ✅ | Honoured here, once per request: the service sends a signed-in user to sign in, and the user coming back with the stored state is not sent again. Corrected at 0.163.0, measured: this row said Duende only models it — its Saml2SsoInteractionResponseGenerator sends a signed-in user to the login page for it, and its callback sends the user back again when auth_time is before the stored state; here the decision is the same generator's (ISaml2SsoInteractionResponseGenerator, row 39) |
IsPassive |
✅ | ✅ | Honoured: a passive request that would need interaction is answered with a signed Responder/NoPassive response POSTed to the reply address, not a redirect to the login page. Corrected at 0.163.0, measured: this row said Duende only models it — its Saml2SsoInteractionResponseGenerator answers it with the same error and Duende's two messages, which the default here now gives; the sign-on endpoint refuses a host generator's login page for a passive request |
NameIDPolicy/@Format |
✅ 16 members | ✅ | Honoured, or refused with InvalidNameIDPolicy — against SupportedNameIdFormats, before sign-in, as of 0.134.0 |
| Signature algorithms | ✅ RSA + EC, configurable allow-list | ✅ RSA + EC as of 0.62.0, pinned | Six signature methods (rsa-sha256/384/512, ecdsa-sha256/384/512) and three digest methods, SHA-1 in neither. Both roles, both bindings. Still pinned in code rather than configurable, which was never the gap: the gap was the shape of the narrowing, and an EC partner could not be talked to at all. .NET ships no ECDSA SignatureDescription for XML signatures, so this package registers three — see below 0.109.0 lets the service provider choose what it signs with, as Duende's OutboundSigningAlgorithm does: SamlSpOptions.OutboundSigningAlgorithm, for the AuthnRequest, LogoutRequest and LogoutResponse alike; null follows the key as before. It must be one of the six and match the key, so SHA-1 — which Duende can still be told to sign with — is refused at startup here. WantAssertionsSigned is a metadata flag only in Duende's SP and stays true here, because the validator requires a signed assertion |
| Signature-wrapping defences | — | ✅ | Three explicit ones here; see below |
| Second-level status codes | ✅ | ✅ | Nested per §3.2.2.2, and surfaced by the SP so NoPassive is distinguishable from a generic Responder failure |
| Dynamic (stored) SAML providers | ✅ SamlProvider, AddSamlDynamicProvider |
✅ 0.110.0 | A SAML identity provider as a row, served by SamlSpHandler at PathPrefix/{scheme}/signin, /signout-callback and /metadata; Duende's validator checks, plus a refusal of a row with no IdP key. See Dynamic identity providers above |
| Stores | 4 + 2 decorators | ✅ 3 as of 0.61.0 | Duende: service provider, logout session, signin state, plus caching and validating decorators. Here: ISamlServiceProviderStore, ISamlSigninStateStore (0.22.0) and ISamlLogoutSessionStore (0.61.0) — the store set went level then, and 0.71.0 adds the validating decorator — ValidatingSamlServiceProviderStore. The caching one is declined, with its reason corrected. 0.61.0 left both behind one sentence — a caching decorator needs an invalidation story before it needs an implementation — which is about the caching one. The validating one needs no invalidation story at all, being a pure function of the row it was handed, and it was the half closing a hole: a registration's reply address had met nothing but the server-wide scheme deny-list, which answers allowed for the empty string, a relative path and http://, while a SAML response carries a signed assertion and the registration is the only thing that says where it goes. And the caching decorator's stated obstacle was already solved one file over: CorsPolicyCache is a caching decorator with a worked invalidation story — short TTL because it caches a permission, positives only so an attacker-chosen key cannot grow it, a cap, an explicit Clear() — and every part of it transfers, entity ids being attacker-chosen in exactly the way origins are. It is declined for a different reason, which is that there is nothing here for it to make faster: the shipped store is a dictionary, no SAML database store exists, and a host that writes one can cache inside it. This cell was wrong in two ways at once until 0.61.0 read it: it counted 2 where §7 counted 3, and it named ISamlRequestStore, a type that has never existed anywhere in this tree. See below |
| Which provider an external sign-in belongs to | ✅ the template's Items["scheme"] only; unsolicited SAML records none (its template then throws) |
✅ 0.178.0 | Measured from Duende 8.0.1's Callback.cshtml.cs template and CommandResultExtensions.Apply (an unsolicited response's properties carry no relay data). The external sign-in page requires the scheme item, with no fallback to the identity's idp claim, and the service provider records its own scheme for an unsolicited assertion — Ours in that the unsolicited sign-in completes rather than throwing. Until 0.178.0 the fallback let an assertion's idp/sub attributes pick another provider's account |
| Attributes that name protocol claims | passed through (Saml2SecurityTokenHandler.ProcessAttributeStatement, Actor aside) |
✅ dropped, Ours (security), 0.178.0 | sub, idp, amr, auth_time, acr, sid, NameIdentifier, AuthenticationMethod, AuthenticationInstant, Actor and the registration's NameIdClaimType, ignoring case. The handler's own AuthenticationMethod/AuthenticationInstant from the AuthnStatement (0.175.0) stay. SAML Core §2.4/§2.7.3: the NameID is the subject; attributes are statements about it |
| SP correlation and assertion replay | ✅ request state in a Saml2.{RelayState} cookie; TokenReplayCache, an in-process MemoryCache keyed by assertion ID until Conditions/@NotOnOrAfter |
✅ 0.178.0, Ours (security) in scope | Measured from SignInCommand, AcsCommand, SPOptions.TokenReplayCache and IdentityModel's Validators.ValidateTokenReplay. Here: the framework's correlation cookie; the replay entry keyed by issuer and ID in IReplayCache (IDistributedCache, shared across instances when the host's is) until the earlier NotOnOrAfter plus the clock skew — Duende's entry lapses at NotOnOrAfter while its lifetime check still accepts the assertion for the skew after. SAML Profiles §4.1.4.5 |
SP RelayState length |
✅ a short RelayState; the request state in a Saml2.{RelayState} cookie |
🟡 found 0.179.0, not yet fixed | The service provider sends its data-protected state as RelayState — about 750 bytes in the recorded SAML identity provider to OpenID Connect client flow — where SAML Bindings §3.4.3 allows 80; an identity provider that enforces the limit, as this server's own and Duende's do (MaxRelayStateLength), refuses the request (relay_state_too_long). Where the state should live instead (a cookie, as Duende; a cache, which needs one shared by every instance) is a decision left open. Until then a host keeps it server-side through SamlSpOptions.StateDataFormat (the federation gateway guide) |
| Bearer assertion requirements | requires a bearer confirmation; RequireAudience false; Recipient, confirmation NotOnOrAfter and Destination unread |
✅ all required, Ours (security), 0.178.0 | AudienceRestriction naming the SP, a bearer confirmation with Recipient = the reply address and NotOnOrAfter, and a response Destination (when present) = the reply address. SAML Profiles §4.1.4.2–3, Bindings §3.5.5.2 |
Inbound LogoutRequest at the SP |
signature and issuer only (LogoutCommand.HandleRequest) |
✅ Ours (security), 0.178.0 | Also Destination (required, signed), NotOnOrAfter, IssueInstant within DefaultRequestMaxAge + skew, a used-ID cache, and the session's provider and subject must match — otherwise UnknownPrincipal and the session kept. SAML Core §3.7.1, §3.7.3.2; Bindings §3.4.5.2 |
| Redirect signature bound to the message acted on | ✅ HttpRedirectBinding.ParseQueryString refuses both message parameters and duplicates |
✅ 0.178.0 | SamlRedirectSignature.Read(query, messageParameter) at the IdP's single sign-on and logout and the SP's logout; the one-argument Read refuses the same ambiguity. Until 0.178.0 the first message was verified while the endpoint read SAMLRequest. SAML Bindings §3.4.4.1 |
A signed redirect-bound AuthnRequest resumed after sign-in |
✅ answered: SingleSignOnCallbackEndpoint trusts the stored state |
✅ 0.179.0 | The query the request arrived with is kept with its state (SamlSignInState.RedirectQuery) and its detached signature verified again on the return leg, the message read from it. Until 0.179.0 the kept XML was re-validated alone and refused as invalid_signature — only posted signed requests were tested across the detour |
| Inbound size at the SP | unbounded (Saml2PostBinding/Saml2RedirectBinding.Unbind) |
✅ SamlOptions.MaxMessageSize, Ours (security), 0.178.0 |
The response, the inflated LogoutRequest and SamlResponseValidator; the IdP side has bounded its inputs as Duende's does since 0.145.0 |
Four members that were parsed and then dropped — ✅ all four fixed
All four rows below are closed (§1d, and the reasoning in "Status" further down). They are kept in the diagnosis they were found in, in the past tense, because how each was found is the part worth re-reading — the fix is a line in §1d, the method is not.
The §1 sweep run over SamlConstants found seven constants read at zero sites. Five triaged as
harmless — ExclusiveC14n and EnvelopedSignature duplicate the identical BCL constants the
signature code actually uses, and Requester/Responder/VersionMismatch are status codes. The
other two led to real findings, and tracing them turned up two more. All four were the same shape:
the message was parsed faithfully onto a model, and the IdP side never read it.
| # | Member | What the SP asked for | What happened then |
|---|---|---|---|
| 1 | ✅ AuthnRequest/@ForceAuthn |
Re-authenticate the user even if they have a session | Silent SSO from the existing session |
| 2 | ✅ NameIDPolicy/@Format |
Issue the assertion with this NameID format | The static per-SP registration won |
| 3 | ✅ @Version |
— | Compared on the SP side only; the IdP side defaulted it to 2.0 when absent. The stronger claim, that it was never compared at all, was wrong — dissected below |
| 4 | ✅ Namespaces.XmlEnc |
— | Named the XML Encryption namespace in a codebase with no encryption |
1 — ForceAuthn is the serious one, and it is an inconsistency with our own OIDC side.
SamlMessageReader parses it (SamlMessageReader.cs:85), SamlMessageWriter emits it when we act
as an SP, and SamlSingleSignOnEndpoint never consults it. The only condition for sending the user
to sign in is that there is no session at all:
var subject = await AuthenticateAsync(context).ConfigureAwait(false);
if (subject is null) { return RedirectToLogin(context, requestXml, relayState); }
ForceAuthn is SAML's prompt=login — how a relying party demands fresh authentication before a
sensitive operation. The SP is told the user just re-authenticated when they did not. The same
server honours the OIDC equivalent carefully: AuthorizeEndpoint handles prompt=login and
max_age, including a missing auth_time, max_age=0, and the strictly-greater comparison. So this
is not "SAML is less complete than Duende" — it is one server enforcing a freshness demand over one
protocol and dropping it over the other.
2 — NameIDPolicy/@Format. SamlSingleSignOnEndpoint.cs:138 always uses
serviceProvider.NameIdFormat, the registration value, defaulting to unspecified. SAML 2.0 core
§3.4.1.1 requires an IdP that cannot honour the requested format to respond InvalidNameIDPolicy;
we neither honour it nor refuse it, we substitute. This is also why NameIdFormats.Persistent and
NameIdFormats.Transient are read at zero sites: no code path can ever select them. (0.134.0: a request may ask
for any of SupportedNameIdFormats, Duende's list; listing persistent or transient issues sub under that label,
as Duende's default generator does.)
3 — @Version: this one was overstated, and the way it went wrong is worth keeping. The first
draft of this section said Version was read and never checked. It is checked —
SamlResponseValidator.cs:75 compares response.Version and fails with version_mismatch. The
search that "proved" otherwise was grep -rn "\.Version\b" | grep -v SamlConstants, and the
comparison reads response.Version != SamlConstants.Version. The filter removed the one line that
disproved the claim — the same failure mode as reading type names, one level down: a search narrow
enough to be convenient is narrow enough to be wrong.
What actually remains, and it is smaller:
SamlMessageReader.cs:54defaults a missingVersionto2.0. The attribute is required by the schema, so a message omitting it passes by substitution rather than by conformance.ReadAuthnRequestdoes not readVersionat all. The SP side checks; the IdP side does not.StatusCodes.VersionMismatchis read at zero sites because the check answers with a local"version_mismatch"string rather than the SAML URN — a vocabulary mismatch, not a missing check.- Nothing tests any of it.
4 — Namespaces.XmlEnc. At the time: no occurrence of "encrypt" anywhere under
src/Fdyay.IdentityServer.Saml. An <EncryptedAssertion> was not something this SP could process,
and many production IdPs encrypt assertions by default. This was the one row where the gap was a
feature rather than a dropped value — and it is the row that took the most work to close. The claim
is now false in six files (SamlEncryption, SamlConstants, SamlSpHandler, SamlSpOptions,
SamlResponseValidator, SamlMetadataWriter); the SP half is implemented and the IdP half is
backlog item 14, closed in 0.55.0.
What is stronger here than the ratio suggests
SamlSignature is not thin. It carries three signature-wrapping defences — the signed ID must be
unique across the whole document, there must be exactly one signature and it must be a direct child
of the signed element, and exactly one reference which must cover that element by ID — and then pins
algorithms: RSA-SHA256 signature, SHA-256 digest, and a transform allow-list admitting only
enveloped-signature and exclusive-C14N, so canonicalization-with-comments, XPath, XSLT and base64
transforms are all rejected.
That is why the "RSA-SHA256 only" row was 🟡 rather than ⬜. Duende's configurable
DefaultAllowedAlgorithms and EC key support are broader; narrower and pinned is a defensible
posture for a server with one deployment, and it should be recorded as a decision rather than a gap.
It becomes a gap the first time a partner IdP signs with EC.
0.62.0 closed it, and the sentence above turned out to understate what it was. "Narrower" reads as this server would verify an EC signature less thoroughly, or not offer EC itself. What the code did was different in kind: every verification path wrote
certificate.GetRSAPublicKey()and treatednullas a failed check, so an EC certificate was not a weaker signature — it was no signature, refused asinvalid_signatureby a verifier that never looked at it and could not tell it apart from a forgery.SignElementthrew outright, so an EC key could not be configured either. A row recorded as a choice about breadth was in fact a refusal at the boundary, and the difference is the difference between a partner seeing fewer options and a partner being told their certificate is invalid.The fix's shape is one type, not six edits. Six call sites each asked a certificate for an RSA key;
SamlSigningKeysis now the only place that asks what family a key is, what this server signs with given one, what hash a named method means, and whether a key is the family a message named. Fixing six sites individually is how a rule comes to be applied to five — note 11, pre-empted rather than paid. A seventh site added later inherits both families by asking this type, or neither by reaching past it.The key must be the family the message named, and that check is new rather than inherited. With one accepted algorithm, "the method is the one we pinned" and "the key is the kind we expect" were the same sentence. With six they are not, and a verifier checking only the first hands an RSA key a signature made by an EC one and lets the crypto layer decide what that means. An allow-list does not just widen what is accepted; it splits a check that used to be one thing into two.
Both families carry SHA-256, SHA-384 and SHA-512 because "SHA-384 is acceptable under EC and refused under RSA" is a rule applied to one of its halves with nothing behind the line. The property worth keeping is SHA-256 or stronger, and SHA-1 is out of both because it is broken for signatures rather than because of which key made it. Digest methods are a separate list, since XML-DSig lets them differ and a strong signature over a SHA-1 digest proves only that the sender vouched for a digest anybody can collide.
Method note 33 (0.62.0): where a fact about the platform is load-bearing, measure it — the build is the only oracle that is not a recollection. The implementation needed to know whether
System.Security.Cryptography.Xml.SignedXmldrives ECDSA through the standardxmldsig-moreURIs unaided. Nothing in this tree had ever asked. The release deliberately shipped a commit that did not know: it tried the direct route, named the uncertainty in the commit message, and left nocatcharoundCheckSignaturethat could swallow the answer into afalse.CI answered in one run —
SignatureDescription could not be created for the signature algorithm supplied— and answered something better than the yes/no asked for. Every detached redirect-binding EC test passed on that same run and every enveloped one failed, which located the problem exactly: this assembly's own crypto was already correct, and the framework's table was empty. A guess that had gone the other way would have produced signature plumbing nobody needed; a guess that had gone this way would have produced the right code for a reason nobody checked, and the split that made the diagnosis free would never have been seen.
Method note 34 (0.63.0): a true sentence about the successes reads as a claim about the coverage. The README said, of SAML: "Single logout and the SAML SP side were already covered and are not new here — SLO raises
UserLogoutSuccessEventthrough the shared sign-out sequence, and an inbound assertion reachesUserLoginSuccessEventthrough the external-login page." Written by 0.51.0, and every word of it true. All of it about successes. Three of SAML's four role-and-operation cells recorded no refusal at all, and the sentence asserting the area was covered sat exactly where the gap was.Note 19 already says a true sentence is the most effective place for a defect to hide. What 0.63.0 adds is which true sentence, and it is reusable: the success path and the failure path are frequently not two branches of one method but two different ways out of the building. A refused assertion never reaches the login page; a refused logout never reaches the sign-out sequence. So a route that carries the successes is evidence about successes and about nothing else, and "X is covered because it reaches Y" is only ever a claim about the half that gets there.
The check that follows from it is cheap and now standing: for any capability that can refuse, ask what the refusal reaches — separately from what the acceptance reaches. Run across the tree it found the other half of this release too:
DefaultSenderConstraintValidatorrefuses six ways and held no logger, so its silence was total rather than merely eventless, and nothing that looked at raise sites would have seen it because there was nothing there to look at. A member sweep finds members; this finds absences, and an absence has no name to grep for.This is note 22's rule pointed at a capability rather than a default. Note 22 says a setting that does not exist still has the library's value; this says a capability the library does not have still has an answer, and the answer is cheaper to observe than to remember. It is also why 0.54.0's "never change a wire-visible error code from memory of a specification" and 0.58.0's "never assert a framework default from memory" are one rule: the failure mode is not being wrong, it is being confidently unverified in a place where verification costs one CI run.
Method note 35 (0.64.0): a test that proves the mechanism can still miss the wiring, and the routes a host actually takes are the wiring. The release needed grant data protected. The first draft registered the protecting wrapper inside
AddIdentityServer(), and wrote a test that resolvedIPersistedGrantStorefrom a real container rather than constructing anything — note 18's own discipline, deliberately applied. The test passed the wrong implementation. It registered its store beforeAddIdentityServer(), and that is the one of three routes the naive wiring covers.AddPersistedGrantStore<T>()andAddDapperStoresboth register over whatAddIdentityServer()left, so both would have thrown the wrapper away — leaving protection on the in-memory store, which loses its rows at shutdown, and none on the database store, which is the only reason the feature exists.Nothing about running the test could have found this. It was found by reading every registration of
IPersistedGrantStoreinsrc/and asking, of each, what does this do to the thing I just added — the grep that took a minute and changed the design. Note 18 says a capability whose only exercise is a fixture proves nothing; this adds that a capability exercised through one real route proves that route. A container is not a host. When a service can be replaced, the question is not whether the wiring works but whether it survives, and the answer lives at the replacement sites rather than at the registration.The same reading earned its keep twice more. It found the fifth reader —
DefaultPersistedGrantServicedeserializesDatastraight off the store, so protecting inside the four writers would have fed it a payload it cannot read, and it answers an unreadable row with no scopes, which would have quietly told people they had consented to nothing. And CI then found the mirror image of note 18's warning, in this document's own tests: two Dapper tests asserted the concrete store type on that resolve, so the passing assertion and the new protection were mutually exclusive, and the comfortable fix — dropping the call that wraps the Dapper store — would have deleted the capability to keep the test green. A test that fails because the code got better is a test making a claim it should not have been making; it now asserts both the wrapper and what it wraps.Note 32's standing identifier sweep found one more, and it is the same shape a third time: row 6 of §1c named
SamlRequestState.PeerEntityId, and there is noSamlRequestState— the member is onSamlSignInState, and has been since 0.22.0 closed that row. A row recording a fix, naming a type that does not exist, for four releases after the sweep that was invented to catch exactly this. The sweep is cheap and it keeps paying; what it cannot do is run itself.And the fix missed a copy, which 0.65.0's run of the same sweep found. The name appeared twice — the §1c row and a method note's prose — and 0.64.0 corrected the row. Note 11 at the scale of an edit: the rule was applied to the occurrence that was being looked at. What makes the sweep worth having is precisely that it does not care which occurrence anybody was looking at, so the check after a rename is to re-run it rather than to read the diff.
Method note 36 (0.65.0): a blocker is a measurement, and it expires. Item 3 was graded "21 of 33" from 0.35.0 to 0.64.0, with an honest caveat attached every release: the inventory could not be re-derived because
docs.duendesoftware.comis unreachable from the build environment. Thirty releases treated that sentence as a fact about the environment. It was a fact about one host.nuget.organswers, and had never been tried — and the package is the better oracle anyway, because the assembly is the artefact and documentation lags it. Derived twice, from the XML documentation file shipped beside the assembly and from the assembly's own metadata strings, the two agree: 28, not 33.The reusable part is not "try nuget.org". It is that a recorded blocker names one route and is then read as naming the destination. "The docs are unreachable, so the inventory cannot be re-derived" is two claims welded together, the first checkable and the second not, and the weld is where it went wrong: a caveat repeated every release stopped being a thing to test. This is note 33 — measure the load-bearing fact rather than recall it — applied to the obstacle rather than to the platform behaviour, and note 19's corollary read the other way: a deferral must name a reason, and a reason that names a route has to be re-run against that route rather than quoted.
The check that follows is cheap and now standing: for every blocker this document records, re-measure it when the item comes up, and record the measurement rather than the memory. Run over the other one in the same release,
rfc-editor.organddatatracker.ietf.orgare still blocked — which is now a checked fact where it had been a remembered one, and is the reason theinvalid_request_urirow stayed where it was until 0.96.0 — when the same measurement, run again, still failed, and the row closed anyway: the authority for a parity question is the package being matched, and the RFC was the route to a question nobody needed answered.What the measurement then bought is the second half of the note: a wrong denominator had been hiding a real silence. With 33 as the target, twelve types were "missing", the honest position was that this tree had no sites for them, and item 3 stayed 🟡 against a number. With 28 measured, the gap is five names, of which two are covered under our own, one reports on a capability that does not exist here, and two were silences with sites — a partner left out of a logout, and a sequence whose completion nothing recorded. A count nobody can check does not merely drift; it stops anybody asking what is actually absent.
Method note 37 (0.66.0): a sentence that names two sites is not fixed by fixing the one you were looking at. §7 said, of EC token signing: "the JWKS writer emits
nandeand nothing emitscrv,xandy." That is two claims about two functions. The release read it as one claim about one function, quoted it in its own test file, taughtPublicJsonWebKeyto produce a correct EC JWK — and CI then published that JWK askty=ECwithnandeset to null and no curve at all, which is exactly the outcome 0.15.0's refusal had existed to prevent, reappearing one layer further out.The README had the same sentence with the same shape — "closing it means teaching
PublicJsonWebKeythecrv/x/ymembers an EC entry needs" — and named the same one of the two. So the miss was not a careless reading of one line; it was the same summary written twice, each time naming the producer and not the writer, which is how a two-site fact becomes a one-site fix.This is note 11 turned on the documentation rather than on the code. Note 11 says a rule applied to one of its halves is the recurring defect; this adds that the document recording the defect can itself name one half, and a fix taken from a summary inherits whatever the summary left out. The check that follows is cheap: when a recorded reason contains an "and", treat each side as a separate site and go and look at both — the conjunction is doing work that a reader in a hurry reads as emphasis.
0.65.0 paid the neighbouring version of this when its identifier sweep found 0.64.0's own fix had missed a copy of a renamed type. Same lesson at a different scale: the thing to re-run after a fix is the check, not the diff.
The encryption gap had two halves and both are closed — found 0.52.0, shipped 0.55.0
Item 14 lists "IdP-side assertion encryption" with the blocker inside it: SamlMetadataReader
filters KeyDescriptor to use="" or use="signing". Looking at that turned up two things worth
writing down before anything is built.
The only Encrypt in this repository is a test helper. SamlEncryption in src/ exposes
Decrypt and nothing else; every call to an encrypter is in SamlEncryptedAssertionTests, which
builds encrypted assertions in order to exercise the decrypter. So the SP side can open an
<EncryptedAssertion>, the IdP side cannot produce one, and the code that would produce one exists
only in a fixture — method note 18 at function scale rather than at assembly scale, and the same
shape 0.44.0 paid for.
The two halves of metadata disagree. SamlMetadataWriter publishes an encryption
KeyDescriptor with its algorithms, which is what lets a partner IdP encrypt to this server. The
reader drops any descriptor that is not for signing, so this server can never learn a partner's
encryption key the same way. One file's job, done in one direction.
Why neither was fixed in 0.52.0, and it was a reason rather than a destination. Removing the
reader's filter on its own would put an encryption certificate on the descriptor that nothing reads —
§1e's fifth shape, committed on purpose, in the document that catalogues it. The reader change is
only correct alongside the encrypter that consumes it: SamlServiceProvider gaining an encryption
certificate, SamlEncryption.Encrypt, and SamlResponseGenerator producing the encrypted form. They
ship together or not at all — and in 0.55.0 they did.
0.55.0, the whole slice. SamlEncryption.Encrypt is the mirror of the Decrypt that shipped
first: a fresh AES-256 content key per assertion, RSA-OAEP wrapped to the recipient, neither
configurable, because the padding this refuses to read is the padding it must never write.
SamlServiceProvider.EncryptionCertificate is the registration that turns it on, and a registration
without one still receives a plain assertion, which is what every partner integrated before this
expects. SamlMetadataReader matches on the declared use instead of skipping everything that is
not signing, so an encryption descriptor is read as one and never offered as something to verify a
signature against; an unmarked descriptor answers both queries, which is what §2.4.1.1 means by
making the attribute optional.
Sign, then encrypt, and that ordering is the release. The signature has to travel inside the
ciphertext so the receiver verifies the plaintext that actually came out rather than an envelope
somebody wrapped around it — the property SamlSignature's wrapping defences exist to protect, and
the reason SamlResponseValidator decrypts before verifying. The two sides are only correct
together, and a round trip that got the order wrong in both directions would pass every
functional test. So it is asserted structurally instead: no Signature element survives in the
clear, because the only one there is signs the assertion and the assertion is ciphertext.
The third half, which the 0.52.0 write-up did not name. SamlSpOptions.DecryptionCertificate
and RequireEncryptedAssertion are the entire host-facing surface of inbound encryption, and
0.53.0's option sweep found that no test set either. Encryption inbound was exercised by driving
SamlResponseValidator and SamlMetadataWriter directly — note 17, a fixture standing where the
real caller stands — so SamlSpHandler, which is what actually reads those two options, had never
once run with encryption configured. The sentence above, "the SP side can open an
<EncryptedAssertion>", was therefore true of the validator and unverified of the handler a host
deploys. Both now run through the real federation round trip, and the fixture's upstream IdP
encrypts using this release's own encrypter, so the two directions verify each other.
A test of this release's own passed while the feature did not exist, and that is worth more than the diff.
The_receiver_can_open_it_and_read_the_subjectasserted a successful validation and the right subject — both of which a plaintext assertion satisfies, because the validator accepts one while holding a decryption key. So it was green against a server that ignored the encryption certificate entirely. The ninth method note, produced in a test written by somebody who had just finished citing it: where a rule is about telling two things apart, an assertion that both outcomes satisfy proves nothing and proves it in green. It now asserts the wire form first.The same run showed the opposite case.
RequireEncryptedAssertion's refusal test passed too — and there the pass was the finding: the option already worked through the handler and nothing in the suite said so. A coverage gap and a defect look identical in a sweep and differ entirely in what they cost, which is why 0.54.0's note about the 49 candidates insists each one is read rather than counted.
Status
All four are fixed, tests first — the failing run is on the branch, and three of the four produce a valid-looking outcome when they are wrong, so writing the tests afterwards would have checked the implementation rather than the protocol.
IsPassive followed, as the mirror of ForceAuthn in the same decision: one asks to always
re-authenticate, the other to never interact, and together they ask for the impossible — which
§3.4.1 resolves the safe way, do not interact and say so. Both reach one branch, because "does this
need interaction" is the same question either way.
SP metadata followed for the same reason encryption needed it: this server could open an
<EncryptedAssertion> and published nothing a partner could encrypt to. The algorithms it
advertises and the algorithms SamlEncryption accepts are now one list, because an algorithm
advertised and refused is a legitimate choice a partner makes and we then fail on every assertion.
Interactive sign-on worked only from an existing session until 0.19.0. The endpoint has always
redirected an unauthenticated browser to the login page with a return URL naming itself, and the page
refused that URL and sent the freshly authenticated user to the site root — so a service provider that
reached a user without a session was never answered. SamlReturnUrlParser, registered by AddSaml,
is what the login page now consults; see IReturnUrlParser in §8, and the method note in §1e for how
a green suite covered this for four releases.
The sign-in state store arrived in 0.22.0, and it was never only a parity row. The endpoint used to
re-encode a POST-bound AuthnRequest as an unsigned redirect query to carry it in the return URL, so a
service provider that signs its requests and posts them was refused when the browser came back — for a
signature it had sent, because the two bindings carry one in different places. The validated request now
stays here and the browser carries an opaque reference. That also absorbed ForceAuthn's separate
record: coming back with a state is what "already sent this one to sign in" means, so one round-trip
record does what two overlapping ones did.
What is still absent: the two store decorators, and nothing else. The four other items this paragraph used to list have all shipped — encryption in 0.55.0, IdP-initiated SSO in 0.59.0, the logout-session store in 0.61.0, and EC signature algorithms in 0.62.0 — and the encryption sentence was wrong for four releases while a section of this same document, added by the release that closed it, said so plainly a hundred lines further down.
0.60.0 found the same shape in the row above, one release later, and it is worth being precise about what kind of error it is. The single logout row said ✅ on both sides. That was true of three of the four directions a two-role server has, and the fourth — this server as a service provider, receiving a
LogoutRequestfrom the identity provider — did nothing at all: the message was decoded, found not to unprotect as our own relay state, and answered with a redirect to/. Nobody was signed out of anything. The row was not stale the way 0.55.0's encryption rows were stale; it was never checked at that resolution. A capability with two roles has four directions, and a tick that covers a role covers whichever of its directions the person writing the tick was thinking about.It is also §1e's oldest shape reached through metadata rather than through a member: the SP publishes
SingleLogoutServiceand names the path, so a partner configured from that metadata sends logout requests there and believes sessions are ending. Nothing in a member sweep looks at what metadata advertises, which is the same blind spot note 22 named for library defaults — an advertisement is a claim this server makes, and no query oversrc/reads it.
Method note 32 (0.61.0): a correction inherits the resolution of the claim it corrects. 0.61.0 found the shape above inside 0.60.0's correction of it, which is the part worth keeping. The rewritten row said, of this server as an identity provider, that it "receives a
LogoutRequestand answers it, and sends one to every service provider the session reached". The first half was true. The second was not, and nothing sent anything to anybody:AuthorizeEndpointwas the only caller ofAddClientIdAsync, so a SAML sign-on left no trace of itself in the session and there was no list to notify even in principle. A release whose whole subject was a tick counted at the wrong resolution wrote a new claim at the wrong resolution in the same sentence — because "as IdP" was still being audited as one thing, when receiving a logout and propagating one are separate capabilities that happen to share an endpoint. The note above says a tick covers whichever direction its author had in mind; this says the correction inherits that unless it is re-derived from the code, and that the release closing a row is the least likely one to re-derive the rest of it.The
Storescell two rows down was wrong in a way no amount of re-reading it would have caught. It namedISamlRequestStore, which has never existed in this tree under that name — not renamed, not removed, never written — while §7, describing the same stores, counted three and named them correctly. Two cells in one document disagreeing about the same fact is 0.59.0's encryption finding exactly; what is new is that one of them named a type that does not exist, and a document is the one artefact in this repository where that compiles. A claim about the tree is checkable against the tree, and the cheap check — grep the identifier — was never run on a cell nobody was editing.So 0.61.0 ran it over the whole document rather than over the cell that failed, which is note 15's rule — a claim about completeness is verified against the tree, not asserted. Every backticked PascalCase identifier in this file was checked against
src/: 346 candidates, and the twenty that do not appear there are all Duende's own types and option names, which is exactly what the rows naming them say about them.ISamlRequestStorewas the only one this document claimed as ours, and it is the one that did not exist.Re-running the sweep in 0.62.0 turns that name up once more — in this paragraph, which quotes it in order to say it never existed. A checker looking only at whether an identifier appears in
src/cannot tell a claim from a post-mortem, so the hit stays and this sentence is what stops somebody "fixing" it.
A document that contradicts itself is worse than either of its halves, and this one did for four releases. 0.55.0 shipped IdP-side assertion encryption and wrote it up in full — "the encryption gap had two halves and both are closed" — and left the §4 table row and this summary saying the half was missing. Epic #40 says that when the issue and the matrix disagree the matrix is right, which is exactly the promise a self-contradicting matrix cannot keep. Note 15 is "a claim about completeness is verified against the tree"; the corollary 0.59.0 pays for is that a claim already written stays a claim, and shipping the thing it denies does not update it. Both were found by reading the rows this release had to change anyway — which is the only reason they were found at all, and not a method anybody should rely on.
12. Whole-surface re-measure (after 0.126.0)
Taken; the full record is surface-remeasure.md. Every public type and member of
Duende.IdentityServer, .Storage and .AspNetIdentity 8.0.1, read from the assemblies by
tools/surface-diff and compared with this repository's seven IdentityServer assemblies: 723
Duende types, 389 matched here, 334 with no counterpart, and 806 members missing from the matched ones. Every item was
triaged by hand — 315 PRESENT under another shape, 149 already RECORDED, 438 PLUMBING, 23 NON-GOAL, 278 GAP — and
the security-relevant gaps were re-read in the code before being recorded. Deduplicated, the gaps make twenty-two
releases, eleven of them security fixes, and eight decisions; the earlier releases' "recorded for the whole-surface
re-measure" items are each given an outcome there.
The findings this document most needed and did not have, each verified at its line:
profile-service claims reach tokens unfiltered (a profile service's scope claim widens a token); IsActiveAsync is
never asked at authorize, for access-token validation or at SAML sign-on (✅ 0.128.0; "identity-token hints" was an over-count — Duende never asks there); a sign-out that does
not start at /saml/slo reaches no SAML service provider (✅ 0.129.0); device codes are stored raw (✅ 0.131.0); an empty grant filter deletes
every grant (✅ 0.131.0); SAML NameIDs are always sub whatever format is registered, every assertion claims
PasswordProtectedTransport (✅ 0.134.0 — an emailAddress NameID is the email and the class reference comes from acr/amr;
persistent is still sub, as Duende's is), inbound SAML requests are not checked for age or destination, and the redirect binding
inflates without a bound (✅ 0.133.0); and form_post for the code flow was deleted on a wrong premise (see the implicit row; ✅ 0.130.0).
Release 13, what a token-request hook can reach, is ✅ 0.148.0 for the token request — the per-request lifetime, type and
client claims, Secret, AuthorizationCode, the extension grant's Request and CustomResponse, and the offline_access
rule — with the other validated requests, ResourceValidationRequest, RequestObjectValues and CIBA's stored auth_time/idp
moved to release 29. Release 12, the authorize interaction seam, is ✅ 0.142.0 for its core — the generator, prompt=create, PromptModes/MaxAge
after a custom validator, custom redirects, unmet_authentication_requirements — with the authorize response generator,
IUiLocalesService, AllowOriginInReturnUrl and token-issued events moved to release 23 and the SAML denial to release 24.
Release 18, the SAML service provider model, is ✅ 0.143.0 — several endpoints with bindings, several certificates with a use,
Enabled, per-provider lifetime, signing and IdP-initiated switch, GetAllSamlServiceProvidersAsync, IConnectedApplicationStore
— with RequireSignedLogoutResponses and AllowedSignatureAlgorithms, found doing it, given release 28.
Release 19, SAML signing and metadata, is ✅ 0.144.0: ISamlSigningService over the server's key material, so a key rotation
reaches SAML, the open metadata generator, and SamlMetadataOptions.
Release 20, SAML configurability, is ✅ 0.145.0 — endpoint paths, bindings and switches, the two state lifetimes and the
login page's extra context — with ISaml2SsoResponseGenerator moved to release 25 and ILogoutRequestValidator and the
read-error inspectors (from release 7) to release 26.
Release 25, the SAML response generator seam, is ✅ 0.152.0: ISaml2SsoResponseGenerator and its open default in Duende's steps,
answering both sign-on doors and release 24's denial, with every value the response path held kept out of a step's reach.
Release 26, the SAML inbound seams, is ✅ 0.153.0: ILogoutRequestValidator with Duende's six steps lifted out of the
single logout endpoint, the trust out of an override's reach, and the three read-error inspectors over a reader that collects
its errors — with Duende's session match in ValidateSessionAsync moved to release 34 for a decision, since either outcome
changes what this server does with a verified request.
Release 28, the SAML inbound signature policy, is ✅ 0.154.0: RequireSignedLogoutResponses on the options and per
provider, for a LogoutResponse only, and a provider's AllowedSignatureAlgorithms wherever its signature is checked —
narrowing, never widening, the methods this server pins.
The eight decisions were taken on 2026-10-13 and are recorded in the re-measure. Release 14, open defaults (decisions 5
and 8), is ✅ 0.147.0: token creation, the claims service, the refresh-token service and back-channel logout opened in
Duende's protected steps, and IHttpResponseWriter<T>, with every check where this server is stricter than Duende made
where no override reaches it.
Release 23, the authorize response seam, is ✅ 0.149.0 — IAuthorizeResponseGenerator, AuthorizeResponse, token-issued events from
the authorize endpoint, IUiLocalesService and AllowOriginInReturnUrl — with the end-session half of ui_locales moved to release 30.
Release 22, small host conveniences, is ✅ 0.150.0 — IssueClientJwtAsync and LogoutTokenJwtType as Duende's, named
local-API schemes, the distributed-cache OIDC state formatter, test CIBA users and CreateUser, the error message's
display, locales and trace, DeleteOneTimeOnlyRefreshTokensOnUse (off here), Resource.Properties,
AuthorizationCode.Properties, OidcProvider.UsePkce and the opt-in ProtectedDataMessageStore<T> — with
IUserSession.GetUserAsync/RemoveSamlSessionAsync, the handle length and Diagnostics moved to row 31.
Release 29, what the other validated requests carry, is ✅ 0.155.0 — the five other IValidatedRequests with their issuer,
options and session, the request object's claims, and the resource indicators judged by the resource validator (a request
wrong in both ways still refused for its scope first, where Duende answers invalid_target) — with CIBA completion's
auth_time and idp and the token request's default scopes, which would change what existing clients receive, given
release 33.
Release 30, the end-session half of ui_locales, is ✅ 0.156.0: ValidatedEndSessionRequest.UiLocales, a local logout page told
through IUiLocalesService, and LogoutRequest.UiLocales.
Release 27, Duende's names and stored values (decisions 3, 4 and 7), is ✅ 0.157.0 and breaking: ProtocolTypes.Saml is
saml2p (an error message stored with saml read as saml2p), Duende's json claim value type written as JSON, eight CIBA
types spelled Backchannel and the two SAML sign-in state stores Signin, and the CIBA events in
EventCategories.BackchannelAuthentication — and, found doing it and decided on 2026-10-14, rows 35 and 36 in the
same release: the CIBA members spelled Backchannel with the endpoint name's value Duende's, and Duende's double
claims written as numbers.
Release 31, session and handle conveniences, is ✅ 0.158.0 — IUserSession.GetUserAsync and RemoveSamlSessionAsync and
IHandleGenerationService.GenerateAsync(ct, length) as default interface methods, nothing breaking; Diagnostics declined;
the endpoints not switched to GetUserAsync (decided 2026-10-14).
Release 34, the SAML logout session match, is ✅ 0.159.0, opt-in as decided on 2026-10-14: Duende's ValidateSessionAsync
checks behind SamlOptions.MatchLogoutRequestToSession or the provider's own switch, read in the step so an override still
replaces it, with ValidatedLogoutRequest.Subject filled by the endpoint; nothing changes by default.
Release 33, a CIBA approval's facts and a token request's default scopes (decided 2026-10-14), is ✅ 0.160.0: completion
records the approving user's sign-in, with auth_time and idp (now and local when missing, as Duende's), and the
tokens collected carry them; and Duende's default scopes for a client_credentials or extension grant request naming none, with
Succeeded's granted-scope rule at the token request, behind TokenRequestDefaultScopesFromResourceStore, off by
default. Row 37, the approver's other claims, decided the same day (adopt), is in the same release: the request stores the
approving user's whole sign-in as Duende's Subject, so the tokens carry its amr and acr too.
auth_time and amr with OpenID Connect Core 1.0 §2's JSON types (decided 2026-11-03, a recorded breaking change before
1.0) is ✅ 0.173.0: auth_time a JSON number and amr a JSON array of strings in every token, at userinfo and at
introspection, as Duende 8.0.1's GetStandardSubjectClaims and CreateJwtPayloadDictionary write them, measured — found
from the recorded flows, which showed "auth_time": "1793660261" and "amr": "pwd".
IConsentMessageStore, the last 🟡 row of this matrix and in no release of the plan, is ✅ 0.161.0: Duende's store and
Message<TModel> over this server's consent message store and cookie, with Duende's ConsentRequest id not taken (§7).
Row 39, the SAML interaction and logout response seams (G2 and G3 of the final re-measure, decided 2026-10-15: build,
non-breaking), is ✅ 0.163.0: Duende's ISaml2SsoInteractionResponseGenerator and ISaml2SloResponseGenerator, each the
endpoint's own code lifted out unchanged, with what an answer must respect held by the endpoint and the run methods.
Duende.AccessTokenManagement was measured too (50 types, 6 matched) and handed to
#39: a separate product, not 1.0's.
Progress on #39: the client-credentials half is Fdyay.AccessTokenManagement; the user-token half,
Duende.AccessTokenManagement.OpenIdConnect's, is Fdyay.AccessTokenManagement.OpenIdConnect (0.166.0) — the two
calls the Shell BFF makes, measured from 4.2.0 and compiled against the Shell's own lines, with its differences and
omissions (DPoP, options, revocation) in its README.
What this does not yet cover
Named so the gaps in the gap analysis are visible rather than implied:
Behavioural differences where both sides have the same type. Two servers can both expose
IResourceValidatorand disagree about what an invalid scope does. Only tests find those.Undocumented public types. The Duende figures are a floor; the XML omits anything without an XML doc comment. This is now measured rather than assumed:
AlwaysSendClientClaims,ClientClaimsPrefixandAllowedAccessTokenSigningAlgorithmsare all absent from the XML and all present inDuende.IdentityServer.dll. Check the assembly before recording anything as missing. Note also that the shippedlib/assembly carries no method bodies, so default values of undocumented members cannot be read from it.AlwaysSendClientClaimsandClientClaimsPrefixare implemented on defaults chosen here — withhold, and prefix — rather than on Duende's, which are unreadable; a host that needs the other shape sets it explicitly.Whether the 🟡 rows are narrower in ways that matter. Each needs a decision, not a count. (None is left since 0.161.0; what each closed row records as "Ours" is where that question now lives.)
Options that are read but not honoured.Now checked. §1b found this class inEndpointsOptionsbecause every member of that group was checked together; the same question has since been put to every option group, and no other group repeats it — see §1e. The one thing that sweep did turn up is worse than the class it was looking for:KeyManagementOptions.Enabledis not read in a place that does less than its name implies, it is read nowhere.Whether a test proves what its name says. The front-channel logout test read the notification URL out of the rendered markup and parsed it without decoding, so
&made every parameter after the first parse under a key prefixed withamp;. It reported the server omittingsidwhen the server was emitting it. A test can fail for a reason that has nothing to do with the code it names, and the failure reads identically either way.Anything below the unit-test line, as this document was first written. None of it had been run: no .NET SDK was available in that environment and the toolchain host was blocked by egress policy, so every claim was from reading the tree and none from executing it. That is no longer the state of the repository — six test projects exist and CI has run the full suite on every merge since. It remains the state of some sessions that edit this file, including the one that added §1e, so the distinction is worth keeping rather than deleting: a claim here about behaviour is only as good as the test named beside it. §1e's rows are read-site and write-site counts over the tree, which is the one kind of claim that does not need a compiler — and the note under row 2 is what happens when a count like that is taken on trust anyway.