feat: coalesce concurrent CIMD document fetches for the same client_id - #312
Conversation
The resolution cache only helps once a fetch has COMPLETED. Until then every arriving request was a miss and started its own fetch, so N simultaneous first-time requests for one client_id meant N DNS resolutions, N TLS handshakes, and N x up-to-5s of request occupancy for a document that is byte-identical every time. That multiplier was free to the caller and needed no credential: client resolution runs ahead of the PrincipalResolver chain (#285), so an unauthenticated request reaches the fetch, and concurrency was the only input required. ResolveClient now collapses concurrent misses for the same client_id into a single flight via golang.org/x/sync/singleflight; the rest wait on that result. The fetch/validate/synthesize/cache body moves to resolveUncached so the callback stays readable. Two details that are easy to get wrong: - The flight re-checks the cache before fetching. A fetch may complete between the outer miss and entering the flight, and starting another one would be duplicate work the flight itself cannot see. - Each waiter gets its OWN clone. singleflight hands the same value to every caller, and callers receive a mutable *domain.OAuthClient -- the same reason cachedResult already returns a copy. Sharing one instance would let any waiter mutate what the others hold. Scope, stated plainly because it is easy to over-read: this bounds DUPLICATE CONCURRENT work for one client_id. It does NOT bound distinct-URL abuse -- a caller cycling unique paths gets a fresh flight each time, misses the cache, walks past negative caching, and churns eviction. Only an edge rate limit closes that, which docs/cimd.md now says explicitly rather than as an aside. The test is mutation-checked: bypassing the flight makes it report 8 fetches instead of 1. Its handler blocks until every caller has arrived, so an implementation that serialises rather than coalesces fails too. It also asserts the waiters hold distinct clients and that mutating one caller's RedirectURIs does not change another's. Passes under -race. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
As a couple of side notes... the database, or shared distributed caches are the safe places to store the CIMD Metadata. The reasons is simple - you might have multiple running instances of ZeroID. The CIMD specification, as of the time this comment was written, is currently in its second draft, and my guess is we're going to see changes in it. |
|
Both good calls, thank you — and the first one changed how I'd frame this PR. On the per-instance cacheYou're right, and it's worth putting a number on it: prod AuthN autoscales 2–6 replicas, so One distinction I'd draw before we reach for shared storage, because I think it splits the problem in two: A shared cache fixes fan-out and consistency. It does not fix staleness. Redis or Postgres would make all replicas consistently stale rather than inconsistently stale — nothing about shared storage makes a cached document fresher. The security-relevant property is revocation latency: a client that pulls a compromised So I'd treat them as separate decisions:
Today the inconsistency has a concrete edge: two replicas can serve different versions of one document for up to an hour, so a client's own remediation may or may not have taken effect depending on which pod they land on — and they can't tell which. That's the part I find least comfortable, and notably it's the part shared storage doesn't fix. On medium, if we do go sharedI'd argue Redis over the database, fairly strongly. CIMD's defining property is that nothing is persisted — the synthesized client carries Redis is a much better fit — AuthN already has it for backchannel, quarantine and revocation, so no new dependency. One thing I'd want decided deliberately rather than as a side effect, though: what's being cached is My honest read for right now: at 2–6 replicas the fan-out is a handful of extra fetches per hour, and the abuse case it amplifies is bounded by edge rate limiting on On the draft movingAgreed, and this PR now records where we stand — I've added a "Specification revision and deviations" section to The strictness is the part your comment made me want written down. Rejecting a query string (draft says only SHOULD NOT) and requiring The failure mode I'd flag hardest is the discovery field name. If Two things that already make drift survivable, which I've also written down: DCR is retained as a deliberate fallback, and registry-first resolution means any client caught by a spec change can be pinned by registering it, overriding whatever its document says. |
…cess cache
Follows review on this PR. Two gaps in docs/cimd.md that the reviewer's
questions exposed: nothing recorded WHICH draft revision the
implementation targets, and nothing warned that the resolution cache is
per process.
Adds a "Specification revision and deviations" section covering:
- the revision implemented against
(draft-ietf-oauth-client-id-metadata-document-02, WG-adopted Oct
2025), stated plainly as a draft that will change
- the two places ZeroID is deliberately STRICTER than the draft --
rejecting a query string (draft: SHOULD NOT) and requiring
client_name (draft: RECOMMENDED) -- with the reasoning for each and
an explicit note to revisit both on every draft bump. Strictness is
the part that ages badly: a later revision can bless what we refuse,
and then we reject valid clients for a reason nobody remembers
choosing.
- what is deliberately NOT built (confidential clients via
private_key_jwt + jwks_uri, and software_statement), which are also
the areas the draft is likeliest to move in
- the change most likely to break SILENTLY: a rename of
client_id_metadata_document_supported. Nothing errors -- the server
advertises a key clients no longer look for, they fall back to DCR,
and the flow keeps working via the row-per-client path CIMD exists
to remove. Tests do not help, because they assert the server EMITS
the field; they stay green while no client can see it. Written down
because tracking the draft is the only guard.
- what makes drift survivable: DCR retained as a fallback, and
registry-first resolution as a pinning mechanism for any single
client caught by a spec change.
Adds a deployment note that the cache is per process, since this PR's
singleflight coalesces within a process and not across replicas. Names
both consequences -- fan-out of up to N fetches per document per TTL
with per-replica negative caching, and non-uniform staleness where two
replicas serve different versions of one document for up to the TTL.
States the distinction that matters for anyone reaching for Redis: a
shared cache fixes fan-out and makes replicas CONSISTENTLY stale, but it
does not make them FRESHER. Revocation latency is governed by the TTL
and the document's Cache-Control, wherever the cache lives. Also notes
that a shared cache holds redirect_uris -- the primary anti-impersonation
control -- so write access to it is equivalent to choosing where
authorization codes are delivered.
"Limitations / future work" now links to those sections instead of
restating them, so the two cannot drift apart. All three intra-doc
anchors verified to resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed The PR description overstated what this fixes. It said coalescing bounds duplicate concurrent work, which is true, but it did not say that the bound is per process. With prod AuthN autoscaling 2–6 replicas, the fan-out you identified survives this change entirely — the singleflight collapses concurrent fetches within one process and does nothing across them. The new Specification revision and deviations section records:
I have deliberately not changed the caching design in this PR — it stays the in-process improvement it is, with the limitation documented. If you would rather the inconsistency window be closed before this lands, say so and I will pick up shared caching as its own change with the poisoning question settled first; I do not think it should ride in as a caching detail either way. |
What
Collapses concurrent CIMD document fetches for the same
client_idinto a single outbound fetch.The gap
The resolution cache only helps once a fetch has completed. Until then every arriving request is a miss and starts its own fetch — so N simultaneous first-time requests for one
client_idmeant N DNS resolutions, N TLS handshakes, and N × up-to-5s of request occupancy, for a document that is byte-identical every time.That multiplier was free to the caller and required no credential: client resolution runs ahead of the
PrincipalResolverchain (#285), so an unauthenticated request reaches the fetch. Concurrency was the only input needed.ResolveClientnow usesgolang.org/x/sync/singleflight; the fetch/validate/synthesize/cache body moves toresolveUncachedso the callback stays readable.Two details that are easy to get wrong
*domain.OAuthClient— the same reasoncachedResultalready returns a copy. Sharing one instance would let any waiter mutate what the others hold.Scope — what this does not fix
Stated plainly because it's easy to over-read: this bounds duplicate concurrent work for one
client_id. It does not bound distinct-URL abuse. A caller cycling unique paths gets a fresh flight each time, misses the cache, walks past negative caching (which is per-URL), and churns the 1000-entry cache's eviction.Only an edge rate limit closes that.
docs/cimd.mdnow says so explicitly rather than as a trailing aside, and separates what each control actually bounds: the caps bound one fetch, coalescing bounds duplicate concurrent work,allowed_domainsbounds who can aim it.Tracked on the deployer side in highflame-cloud#2358.
Verification
concurrent resolutions performed 8 fetches, want 1. A coalescing test that would pass anyway is worth nothing, so this matters more than the green run.*domain.OAuthClientvalues, and that mutating one caller'sRedirectURIsdoesn't change another's (catches a shallow clone).-race. Fullinternal/...unit suites green.golangci-lint: 0 issues.Context
Found while reviewing why CIMD is disabled in Highflame production. The two blockers were this and the missing edge rate limit; with both addressed, an open-ecosystem CIMD deployment is defensible.
🤖 Generated with Claude Code