fix(users): key OIDC JWKS cache by (issuer, jwks_uri) (bookshelf-mfli8) #1413
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bd-bookshelf-mfli8"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Non-blocking code-review MINOR follow-up from #1405:
internal/users/oidc_cache.go'soidcJWKSCachewas keyed byjwksURIalone, so two distinct trusted issuers sharing ajwks_uricould collide and share a cache entry (a hit for issuer B could silently serve JWKS bytes fetched/trusted under issuer A). Harmless today since a cache hit never re-dials, but a latent footgun.Keys the cache entries map (and the singleflight dedup key) by
(issuer, jwksURI)via a newjwksCacheKeystruct. PublicFetch/Refreshsignatures are unchanged.Test plan
jwks_urieach trigger their own fetch (2 calls, not 1).go test ./internal/users/...passes, 100% coverage on the modifiedoidc_cache.go.golangci-lint run ./internal/users/...clean.Docs: N/A because this is an internal cache-correctness fix with no user-observable behavior change.
Closes bead bookshelf-mfli8 on merge.
Security review of PR #1413 (
fix(users): key OIDC JWKS cache by (issuer, jwks_uri)).Scope reviewed:
internal/users/oidc_cache.go,internal/users/oidc_cache_test.go, and the call sites ininternal/users/oidc_service.gothat invokefetchJWKS/refreshJWKS.Analysis
issuervalue threaded intooidcJWKSCache.get/Refreshoriginates exclusively fromcfg.Issuer— the admin-configured, trusted OIDC issuer (internal/users/oidc_service.golines 294, 302, and all other call sites). It is never sourced from request input, the ID token's ownissclaim, or any other attacker-influenceable value. The new cache keyjwksCacheKey{issuer, jwksURI}therefore keys on a trusted value, which is the correct precondition for this fix to actually close the collision it targets.map[string]jwksEntrykeyed byjwksURIalone, so if two configured issuers (or a misconfigured re-point ofcfg.Issuerat a different provider sharing infrastructure) advertised the samejwks_uri, a fetch cached under issuer A could be served for issuer B without a corresponding fetch — a latent (if narrow, given the app is presently single-tenant OIDC) trust-boundary blur between issuers. Keying by(issuer, jwks_uri)closes that.issuer + "|" + jwksURI), preventing two concurrent fetches for different issuers sharing ajwks_urifrom collapsing into a single shared HTTP call and result. Good — this mirrors the map-key fix and avoids reintroducing the same collision via the dedup path.if hit { serve e.raw }) still keys off the new composite key'shit/e, so the "serve stale on transient error" fallback continues to only serve an entry that was cached under the matching issuer — no regression there."issuer", issuerlog fields log only the issuer URL (already logged elsewhere in this file/package), not JWKS key material or tokens.oidc_cache_test.go) directly exercises the fixed scenario: two issuers sharing onejwks_urieach trigger their own fetch (assertscalls == 2), which is the right regression test for this class of bug.Minor observation (not a functional flaw given current trusted-input guarantees):
internal/users/oidc_cache.go:71—sfKey := issuer + "|" + jwksURIis a naive string-concat key. In principleissuer="a|"+jwksURI="b"collides withissuer="a"+jwksURI="|b". Both inputs are trusted (admin-configured issuer / discovery-document-derived URI, not attacker input), so this is not currently exploitable, but a struct-keyed singleflight (or a delimiter guaranteed absent from URLs, or a hash) would remove the ambiguity entirely and match the map key's type-safety. Given trusted inputs this is cosmetic, not a vulnerability.[MINOR] internal/users/oidc_cache.go:71 — singleflight key uses string concatenation with a
|delimiter instead of a typed/struct keyissuer + "|" + jwksURIcould theoretically collide if either trusted value contained a literal|(neither currently can, since both are admin-configured/discovery-derived URLs, so this is not exploitable today). Prefer a struct key (mirroringjwksCacheKey) or a delimiter guaranteed not to appear in a URL, for defense-in-depth consistency with the map-key fix this PR just made.REVIEW VERDICT: 0 blocker, 0 major, 1 minor
Code review — PR #1413 (bd-bookshelf-mfli8), diff-review only (CI green).
[MAJOR] internal/users/oidc_cache.go:189 — singleflight key built by naive string concatenation can collide across distinct (issuer, jwksURI) pairs
sfKey := issuer + "|" + jwksURIhas no delimiter-collision protection: two different (issuer, jwksURI) pairs can produce the identicalsfKeywhenever either string contains the literal|(e.g. issuer="a|b", jwksURI="c"vs issuer="a", jwksURI="b|c"both yield"a|b|c").jwksURIin particular is sourced frommeta.JWKSURIin the OIDC discovery document — i.e. content returned over the network by the (trusted, but externally-hosted) IdP, not a value under this codebase's control — so a crafted/misconfigured discovery response can produce a|-containing jwks_uri.net/urland Go's HTTP client do not reject an unescaped|in a URL, so this is reachable, not just theoretical.Why it matters: unlike the map (correctly keyed by the
jwksCacheKeystruct, immune to this),singleflight.Group.Dotakes a barestringkey. If two concurrentget()calls for different (issuer, jwksURI) pairs collide onsfKey, only one fetch runs and both callers receive the samerawbytes — and each caller then writes those bytes into its own (distinct, correctly-scoped) map entry. That reintroduces, one layer down, the exact cross-issuer JWKS confusion this PR sets out to eliminate (issuer B's cache entry ends up populated with issuer A's fetched key material).Fix: derive the singleflight key unambiguously from the same struct that keys the map — e.g.
fmt.Sprintf("%d:%s|%s", len(issuer), issuer, jwksURI)(length-prefixing the first field removes the ambiguity) or hash the struct (sha256over a\x00-joined encoding). Do not rely on an arbitrary separator character being absent from attacker/IdP-influenced input.Everything else checked out:
jwksCacheKey{issuer, jwksURI}map key is a genuine struct, correctly scoping the cache (not just the log line); no path still keys the map byjwksURIalone.Fetch/Refreshcall sites are unchanged method-value references; the pre-existing(ctx, jwksURI, issuer)signature was already threading issuer through, so no caller needed updating for this change.jwksCacheis a genuinely separate, single-entry cache instantiated once perOIDCBackchannelLogoutclosure (internal/users/oidc_backchannel.go:355), tied to onecfgfrom a singlegetConfigcall. Pergamum currently supports exactly one configured OIDC provider (OIDCConfigis singular, oneIssuerfield throughout oidc_service.go), so this cache has no multi-issuer surface today — the agent's claim that it needs no equivalent fix is correct, not overclaimed.package users_test, viausers.ExportNewOIDCJWKSCache) asserts two issuers sharing a jwks_uri produce two fetches (calls.Load() == 2), directly covering the fixed collision. Existing singleflight-dedup and TTL/Refresh tests are otherwise untouched and still pass with the new key type."jwks_uri", jwksURI, "issuer", issueradded consistently to both the stale-serve warning and the cache-populated info log — no secrets logged (JWKS is public key material).REVIEW VERDICT: 0 blocker, 1 major, 0 minor
The singleflight dedup key in oidcJWKSCache.get() was built by naively concatenating issuer + "|" + jwksURI. Since jwksURI comes from the IdP's discovery document (network-sourced, IdP-influenced), a literal "|" in it could make two distinct (issuer, jwksURI) pairs collide: - ("x|y", "z") -> "x|y|z" - ("x", "y|z") -> "x|y|z" This would cause one issuer's JWKS to be cached/deduplicated under another issuer's singleflight entry, reintroducing the cross-issuer confusion that the struct-keyed cache correctly prevents. Fix: use length-prefix format for the singleflight key: fmt.Sprintf("%d:%s|%d:%s", len(issuer), issuer, len(jwksURI), jwksURI) This makes collisions impossible regardless of "|" in either value. Add test demonstrating the collision safety: two (issuer, jwksURI) pairs that would collide under naive concat now correctly trigger separate fetches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tRKybTpfjQ4SxmNdVFLHi