Skip to content

feat(core): metadata refresh on the verification path, and identifier gating at construction - #28

Merged
RobertoIskandarani merged 8 commits into
mainfrom
export/labs-to-public
Sep 9, 2026
Merged

feat(core): metadata refresh on the verification path, and identifier gating at construction#28
RobertoIskandarani merged 8 commits into
mainfrom
export/labs-to-public

Conversation

@RobertoIskandarani

@RobertoIskandarani RobertoIskandarani commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Brings the accumulated core work onto the release line so it can ship as 0.1.1. 31 files, +2890 / −170. 985 tests, 0 failures (from 917). No API is removed; two behaviours become stricter at construction time and carry migration notes in the changelog.

Stacked on the changelog PR, which is where the ## [0.1.0] heading comes from. Base retargets to main automatically once that merges.

The one that matters most

Authorization server metadata was never re-read on a resource server that only verifies tokens. Such a server never calls the token, introspection or revocation endpoints, so nothing on its request path touched the metadata document after start-up. The copy fetched at build time was kept indefinitely and a rotated jwks_uri was never followed — metadataRefreshSeconds was configuration that did nothing. Verified against the released tag: AuthplaneResource in v0.1.0 has no refresh call at all.

Verification now reads through the metadata cache before each key lookup.

Why the rest of the cache work is in the same change

Moving metadata onto the verification path is what makes these load-bearing rather than tidy-up. Each is a defect that only bites once the document is read per request, so they cannot land separately without shipping a window where the first change makes the others reachable:

  • A refresh returning an invalid document (wrong issuer, non-HTTPS endpoint, missing jwks_uri) used to displace the good one and repoint key retrieval. Documents are validated at fetch now, and the last valid one keeps being served.
  • A jwks_uri rebind that failed once stranded key retrieval on the withdrawn URI permanently: the rebind fired on the document changing, and the document had already been published to the cache, so every later refresh returned the same document and the change never fired again. One 503 at the wrong moment meant every token failed to verify until restart. The binding is reconciled on each lookup now, so a failed rebind retries.
  • A failed refresh left the document permanently expired — the timestamp only advances on success — so every read took the synchronous re-fetch branch: a full HTTP timeout per verification, serialized behind the cache's fetch lock. Failed refreshes back off up to 30s, and a read that finds a refresh in flight serves what it holds.

Identifier gating

The sinks that splice a resource identifier into a response are now closed at construction rather than one at a time at each sink.

A missing scheme was refused at derivation in 0.1.0. That covered the PRM URL but not the other splice: the DPoP htu target read null://api.example.com/mcp, so every bound request against a scheme-relative identifier failed. Scheme, userinfo and fragment are all gated at construction now, with the derivation checks kept as a backstop.

The derived PRM URL also preserves the identifier's query component (RFC 9728 §3 inserts the well-known string "between the host component and the path and/or query components"). The query is validated against the RFC 3986 §3.4 grammar because it is spliced verbatim into a header field RFC 9110 §5.5 confines to US-ASCII — an unescaped bracket (?filter[a]=b, which java.net.URI, browsers and servlet containers all accept) used to construct cleanly and then throw a 500 out of the 401 path.

Both migrations are in the changelog. The realistic one is the bracket case.

Also here

  • JwksCache and MetadataCache take an optional Clock. This is what makes the backoff and interval behaviour testable without sleeping — the reason it is additive rather than a test-only seam.
  • spring-test as a test-scoped dependency: the new PRM RouterFunction test drives a real request through MockHttpServletRequest/Response.
  • .conformance-catalog-ref moves to the revision the new RFC 8707 and RFC 9728 conformance cases come from.

What is deliberately not here

scripts/backport-fixes.sh and its test suite are untouched here. They are release tooling rather than product, and reworking them alongside a behaviour change would bury the parts above. No follow-up is promised for them in the changelog — this is a scoping note, not a deferral.

Verification

mvn verify green across all four modules against the pinned catalog: 802 + 63 + 120 tests, 0 failures, 2 skipped.

… gating at construction

Brings the accumulated core work onto the release line. No API is
removed; two behaviours become stricter at construction time and are
called out with migration notes in the changelog.

The largest item is that authorization server metadata was never re-read
on a resource server that only verifies tokens. Such a server never
calls the token, introspection or revocation endpoints, so nothing on
its request path touched the metadata document after start-up: the copy
fetched at build time was kept indefinitely and a rotated `jwks_uri` was
never followed. `metadataRefreshSeconds` was configuration that did
nothing. Verification now reads through the metadata cache before each
key lookup.

Moving metadata onto the verification path is what makes the rest of the
cache work load-bearing rather than tidy-up, and each piece is a defect
that only bites once metadata is read per request:

  - a refresh returning an invalid document used to displace the good
    one and repoint key retrieval; documents are now validated at fetch,
    and the last valid one keeps being served
  - a `jwks_uri` rebind that failed once stranded key retrieval on the
    withdrawn URI forever, because the rebind fired on the document
    *changing* and the document had already been published; the binding
    is now reconciled on each lookup, so a failed rebind retries
  - a failed refresh left the document permanently expired, so every
    read took the synchronous re-fetch branch — a full HTTP timeout per
    verification. Failed refreshes now back off up to 30s, and a read
    that finds a refresh in flight serves what it holds

The identifier work closes the sinks that splice a resource identifier
into a response. A missing scheme was refused at derivation in 0.1.0,
which covered the PRM URL but not the DPoP `htu` target, where it read
`null://…` and failed every bound request; scheme, userinfo and fragment
are now all gated at construction, with derivation keeping its checks as
a backstop. The derived PRM URL preserves the identifier's query
component per RFC 9728 §3, and the query is validated against the RFC
3986 §3.4 grammar because it is spliced verbatim into a header field
RFC 9110 §5.5 confines to US-ASCII — an unescaped bracket used to
construct cleanly and then throw a 500 out of the 401 path.

`JwksCache` and `MetadataCache` take an optional `Clock` so the refresh
intervals can be driven from an injected time source; that is what makes
the backoff and interval behaviour testable without sleeping.

Adds `spring-test` as a test-scoped dependency: the new PRM
RouterFunction test drives a real request through
MockHttpServletRequest/Response.

Bumps .conformance-catalog-ref to the revision the new RFC 8707 and
RFC 9728 conformance cases come from.

985 tests, 0 failures (from 917).
…se the review findings

Review found four things that needed action before this ships. Three are
here; the fourth is the release number, which is not set in this diff.

The one that mattered: the backoff did not cover the path a rotation
actually puts every token on. `forceRefresh()` never consulted
`retryNotBeforeEpochSeconds`, and `JwksCache.getKeyByKid(kid, true)`
routes straight into it — which `JwtValidator` calls on every kid the
cached document does not hold. That is exactly the state a rotation to
an unreachable `jwks_uri` leaves behind: tokens arrive signed with keys
the old document does not carry, and each verification paid a full HTTP
timeout on the caller's thread, serialized behind the cache's fetch
lock. Verbatim the failure this change claimed to have fixed. It is also
an amplification surface — an unauthenticated caller sending unknown
kids set the fetch rate.

The existing rotation test could not see it: it reuses one signing key,
so `find(kid, true)` is never reached and its assertion held for the
wrong reason. The new test drives the miss directly, and the negative
control confirms it: with the guard disabled it fails while the old test
still passes.

The bypass is a separate named method rather than a boolean, so no
request path reaches it by flipping an argument. Its only caller is the
test-only metadata refresh, which does want the attempt made.

Second: the RFC 9728 absoluteness case was registered `NONE`/`@Disabled`
with gaps saying scheme-relative identifiers are accepted — which this
same change stopped being true. Enabled and asserted against both
stimuli plus the http-localhost case. PARTIAL rather than FULL: the
requirement is scheme *and* host and only the scheme half is gated at
construction — `https:example.com/mcp` still constructs and fails at
derivation. Understating coverage defeats the point of the catalog as
much as overstating it.

Third, from the Minors:

  - `clock` is volatile. It is written after construction, so unlike
    every other infra field on the class it carries no JMM final-field
    guarantee, and a racily-published client could hand a request thread
    a null.
  - the cause walk in `isInterrupt` is bounded. `initCause` refuses a
    self-reference but not A causes B causes A, which is reachable and
    would not terminate. Both that and the wrapped/unwrapped asymmetry
    the two call sites depend on are now pinned by tests.
  - `wellKnownUrl` backstops all four gates. It ran `requireNoFragment`
    and `requireValidQuery` only, so it still spliced userinfo into a
    401 challenge. The reason the backstops exist — the method is public
    and reachable with a string no constructor saw — does not
    distinguish between the four.
  - the `normalizeRequestUrl` javadoc argued against its own change:
    `%2D` is `-`, an unreserved octet, which RFC 3986 §6.2.2.2 has a
    conformant client decode before comparing, so the raw form would
    fail to match. Replaced with a reserved octet, and the raw-authority
    test now uses a reg-name escape since userinfo can no longer reach
    that derivation.
  - the `authorityBounds` javadoc sat above `bestEffortAuthorityBounds`,
    which has its own; moved.
  - the jetty comment claimed the dependency was example-only, which a
    new test made false. A second test-scope declaration is not the fix
    — Maven keeps the last of two identical coordinates and would strip
    `jakarta.servlet` from the compile classpath. The comment was the
    thing that was wrong.
  - `spring.version` property, so spring-test cannot drift from
    spring-webmvc.
  - the metadata reconcile ran twice per verification on the kid-miss
    path; hoisted to once via a `beforeLookup` hook on `KeyLookup`.

Changelog: the two construction-time rejections move from Fixed to
Changed with migration notes — they narrow behaviour, which is not a fix
from the caller's side — and the backoff entry now says it covers the
kid-miss refresh rather than implying more than it did.

985 -> 988 tests, 0 failures, 1 skipped (was 2).
Both entries said "tracked separately" and pointed at nothing, which is
the shape a deferral takes right before it disappears. A reader of a
public changelog cannot follow an internal tracker, so the follow-ups
are now issues on this repository and the sentences name them.

  - #29 — the authority and path components of the resource identifier
    are not gated at construction, while scheme, userinfo, fragment and
    the query grammar are. Both are spliced into the same 401 challenge
    and the same served document, and escapeQuotedString strips only
    control characters, so the argument that justified the query gate
    covers them unchanged.
  - #30 — the resource form parameter that the two grants send is not
    validated. It carries a caller-supplied string rather than the
    configured identifier, so the fix is not simply the same gates: the
    issue records the question of whether an SDK should refuse a value
    it forwards rather than owns.

No behaviour change.
…ew nits

Four of these are mine from the previous round, including repeating a
defect I had just fixed one file over.

The changelog was silent on the two things `forceRefresh` did. It is a
public method, inherited by the public `JwksCache` and `MetadataCache`,
and it stopped fetching unconditionally: an embedder calling it inside a
backoff window now gets the held document back, and the return type
cannot say which happened. That is a narrowing and belongs under
`Changed` with the others, naming the method — the previous entry
covered it obliquely under `Fixed`, framed as an internal path.
`forceRefreshIgnoringFailureBackoff()` is likewise new public API and
was in no section at all; it is now under `Added`, saying what it is for
and that no request path should call it.

`doForceRefresh` also took the blocking lock while `get()` deliberately
uses `tryLock` — and `forceRefresh`'s own new javadoc makes the same
request-path argument `get()` makes. The backoff stops a *failing*
endpoint costing a fetch per request but does nothing for the burst
arriving before the first failure records `retryNotBefore`, which would
all queue for one HTTP timeout. It now serves the held document when a
fetch is already in flight, so the two methods agree that no
request-path caller waits on another thread's fetch. Re-ran the negative
control after the change: with the backoff guard disabled the kid-miss
test fails and passes again restored, so the backoff is still what that
test pins rather than the lock change.

The changelog also asserted a contested question as settled: "no valid
identifier is turned away — `urn:example:api` still constructs". The
gate is scheme-only and that value does construct here, but whether
construction is the right place to refuse an identifier with no host is
open — cs-sdk refuses it, and the catalog's own wording asks for a
scheme *and* a host. Stated as scoped and tracked instead of decided.

For the same reason the "every implementation of this derivation settled
on the query-less URL" comment now says which sub-case it means. It was
about the empty query, where the family does agree; for a non-empty
query the implementations still differ, and the sentence could be read
as claiming otherwise.

The rest are nits, all mine:

  - `@FunctionalInterface` is back on `KeyLookup`. Dropping it when
    `beforeLookup()` was added was unnecessary — a functional interface
    may declare default methods — and the annotation is what stops a
    second abstract one being added by accident.
  - the `buildClientWithClock` javadoc was orphaned by inserting the two
    `isInterrupt` tests between it and its method, leaving two blocks
    stacked. Exactly the defect the previous commit fixed in
    `ProtectedResourceMetadata`.
  - the "interrupt reachable inside a cycle" case had no cycle in it:
    `d -> c -> InterruptedException` terminates. The loop now closes
    through the interrupt itself, so the comment describes the chain.

988 tests, 0 failures.
…document permanently

`effectiveTtlSeconds()` subtracted the cache timestamp from the server
expiry, so an expiry that was not in the future produced a negative TTL.
`CacheHeaderParser.parseExpiresAt` returns `0` for `Cache-Control:
no-store` and `no-cache`, the current second for `max-age=0`, and a past
epoch for a stale `Expires:` — for `no-store` the result is about
-1.7e9.

A negative TTL is expired on every read, so `get()` took the synchronous
re-fetch branch on the caller's thread every time, forever. The failure
backoff could not cover it: that only arms when a fetch *throws*, and an
endpoint answering `no-store` successfully clears the backoff and
re-arms the expiry on the same call.

This was latent while nothing on a verification path read the metadata
cache. This change series is what puts it there — verification reads
through the cache before every key lookup, and `beforeLookup()` runs
before signature verification, so an unauthenticated caller would have
set the fetch rate against the authorization server. It is the failure
this series exists to remove, reached through a different door.

An expiry at or before the cache timestamp is now read as "no
preference" and the configured interval governs. go-sdk clamps the
equivalent case the same way rather than taking a zero expiry
literally.

Two tests, and the negative control holds: with the clamp removed both
fail and the pre-existing `get_serverExpiresTtl_usesMinOfConfigured...`
still passes, which is why it never covered this — it only exercises a
*future* server expiry.

Also corrects two claims about gate ordering that were not true. The
`requireNoUserinfo` javadoc said it runs "last of the four", and the
changelog said `wellKnownUrl` "enforces the same four gates as the
constructors". The set is the same; the order is not — the three
construction sites run fragment, query, scheme, userinfo and
`wellKnownUrl` runs fragment, scheme, userinfo, query. An identifier
violating two gates can therefore be reported for a different component
depending on the entrypoint. Both reject, so only the message differs;
unifying the four behind one private gate touches four call sites with
different shapes and is left to its own change rather than bolted onto
this one.

990 tests, 0 failures.
muralx
muralx previously approved these changes Sep 8, 2026
@RobertoIskandarani
RobertoIskandarani changed the base branch from docs/record-0.1.0-on-main to main September 8, 2026 20:59
@RobertoIskandarani
RobertoIskandarani dismissed muralx’s stale review September 8, 2026 20:59

The base branch was changed.

…fig that reaches the same permanent expiry

Three things downstream of the server-expiry clamp, all of which the
clamp itself created.

`CacheHeaderParser.parseExpiresAt` now returns `null` for `no-store` and
`no-cache` rather than `0`. Those directives say the response should not
be reused, which for a document this SDK has to keep serving is not an
expiry it can honour — the honest answer is "no usable preference". The
`0` was read as an absolute expiry at the epoch, which is exactly what
made the document permanently stale; once the cache started discarding a
non-future expiry the sentinel became indistinguishable from `null`,
while the class javadoc still claimed `effectiveTTL = min(configuredTTL,
serverExpiry)` and the parameter doc still said "treat as immediately
expired". Both were false as of the previous commit. This is a public
method, so the changelog says what changed for a caller reading the
value directly; go and ts already model the case as absent rather than
as zero.

`DocumentCache`'s constructor now refuses a non-positive refresh
interval and a null clock. `AuthplaneClientBuilder` already rejected the
interval, but `JwksCache` and `MetadataCache` expose these constructors
publicly and this change series argues they are contract — and a zero
interval reaches the same failure through the other parameter:
`effectiveTtlSeconds()` returns it verbatim, `age >= 0` on the first
read, and every `get()` pays a synchronous fetch. The null clock was
surfacing as an NPE from the first read instead of at construction,
which is the reasoning `MetadataCache` already applies to
`expectedIssuer`.

The `Clock` constructors now state the constraint the clamp introduced:
a server `max-age` is turned into an absolute expiry against the system
clock, in the header parser, before it reaches this cache — and the
clamp compares it against a timestamp from the injected clock. A clock
offset far from wall time therefore makes every real server expiry read
as past, so directives are discarded wholesale and the configured
interval governs alone. Safe, but silent, and worth saying on a
constructor documented as supported API for simulation clocks.
Threading the clock into the parser would remove the constraint, and
means changing two public `DocumentFetcher` factory signatures, so it is
tracked rather than folded in here.

Also: `**no preference**` rendered as literal asterisks in javadoc — the
house style is `<em>`; and the scheme migration note said "0.1.0 refused
such an identifier at derivation", which reads as a pointer to that
version's notes now that they say only "Initial release". Reworded to
describe the behaviour, which is what it always meant.

809 + 63 + 120 tests, 0 failures.
Four claims in this branch said something was tracked and pointed at
nothing, next to #29 and #30 which do. A deferral with no reference is
the shape one takes right before it disappears, and a reader of a public
repository cannot follow an internal tracker — so these are issues on
this repository:

  - #31 — whether a resource identifier must carry a host and not only a
    scheme. `urn:example:api` constructs here and is refused at
    construction by another implementation in the family, so the same
    configured value starts one server and stops another. Both readings
    are defensible; the issue records why, and what moves the RFC 9728
    conformance case off PARTIAL.
  - #32 — query preservation in the derived PRM URL is not yet uniform.
    The interop failure is concrete: a client deriving the query-less URL
    receives a document whose `resource` member does not match what it
    asked for, which RFC 9728 §3.3 requires it to discard.
  - #33 — the four identifier gates run in two different orders, so the
    component named in the error depends on the entrypoint. Worth doing
    before #29 adds two more axes.
  - #34 — the clock the caches compare against is not the clock the
    header parser reads, so an injected clock far from wall time
    silently discards server expiries.

Every remaining "tracked" in the changelog and in source now names a
number.
9.40 sits inside the vulnerable range (>= 9.38-rc1, < 10.0.2): a denial
of service on deeply nested JSON. 0.1.0 shipped with it and is on Maven
Central.

The reachability is the part that matters. This library parses the
access token, on the verification path, before any signature is checked
— so the input is attacker-supplied and the caller does not need to be
authenticated. That is the same amplification shape as the two cache
findings in this branch, on a dependency rather than on our own code.

Taken to the current 10.9.1 rather than the minimum fixed 10.0.2,
because both are 10.x and impose the identical major on consumers, so
the minimum buys nothing while leaving us nine minors behind on the
library that does the parsing. The pin carries a comment saying so, and
why the version matters, so the next person does not have to re-derive
it from an advisory id.

Consumer impact is real and is in the changelog: nimbus types are part
of this SDK's public API — `DPoPKeyMaterial.fromJwk` takes a
`com.nimbusds.jose.jwk.JWK` and `publicJwk()` returns one — so anyone
constructing DPoP key material themselves, or pinning nimbus in their
own build, needs 10.x. Callers using only the SDK's entrypoints get it
transitively.

Full suite green on both candidate versions before choosing: 992 tests,
0 failures, on 10.0.2 and on 10.9.1.

Found by checking the repository's code-scanning alerts, which is not
something the PR checks surface — the five green checks on this branch
say nothing about an open advisory on a dependency.
@RobertoIskandarani
RobertoIskandarani merged commit 7fbdbba into main Sep 9, 2026
5 checks passed
@RobertoIskandarani
RobertoIskandarani deleted the export/labs-to-public branch September 9, 2026 12:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants