Repository navigation
fix(scheduledauth): cache unknown token kids to stop per-token JWKS refetches - #1164
Conversation
…efetches go-oidc's RemoteKeySet refetches the JWKS every time a token fails to verify against its cached keys and has no cooldown, so each bad token was proxied 1:1 to the provider's JWKS endpoint. Wrap the RemoteKeySet in a key set that remembers, for 10s (the existing rebuild window), any kid that a JWKS fetch issued after the token arrived still could not verify. Repeated tokens with that kid are rejected without a fetch. A cached kid never blocks a token the latest fetched JWKS verifies, failed or already-inflight fetches never cache anything, and the cache is capped at 1024 SHA-256-keyed entries. Closes LeanerCloud#75 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 4
✨ Finishing Touches
Comment |
|
VERDICT: APPROVE a7405d1 Reviewed: full diff Issue #75 asks for an optional negative-kid cache so that repeated unknown-kid tokens short-circuit without a JWKS fetch, while a genuine rotation still refreshes once, failing closed throughout. Findings (none blocking):
Attacked:
Local results (fresh |
|
Independent review 2 (claude-fleet, cold): APPROVE a7405d1 Findings (none blocking; a related note was shared privately with the maintainers per SECURITY.md):
Against #75: go-oidc v3.18.0 Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
Root cause
go-oidc v3.18.0's
RemoteKeySet.verifyrefetches the JWKS every time a token fails to verify against its cached keys, and it has no cooldown. cli#1386 bounded our own verifier rebuilds to one per 10s, but each bad token still cost one JWKS fetch inside go-oidc. A spammer could proxy requests 1:1 to the provider's JWKS endpoint by repeating an unknown kid, or by reusing a published kid with a forged signature.Fix
internal/server/scheduledauth/kidguard.gowraps the go-oidcRemoteKeySetin aguardedKeySet:jwksObserverRoundTripper sits on top of the existing hardenedpkg/httpclienttransport. It does not replace it. The observer numbers each JWKS fetch as it starts and records the key set from every successful response. If go-oidc rejects a token, and a fetch that started after that token arrived still cannot verify it, the token's kid goes into the negative cache fornegativeKidTTL. Later tokens with that kid are rejected without calling go-oidc, so they trigger no fetch.negativeKidTTLis equal to the existingminRebuildInterval(10s), so a freshly rotated kid is never rejected for longer than the existing recovery bound. Rejection can only happen if the attacker sent that kid before it was published and no other refresh has happened since.Validator, so it survives verifier rebuilds.validator.gonow builds both the initial verifier and the rebuilt verifier through onenewVerifier()helper. The unusedkeySetfield is removed.Closes #75
Verification
All of this is local unit/fixture evidence against an
httptestJWKS server. No real JWKS endpoint was called. Tests ran withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's default AWS profile is invalid. That has nothing to do with this package.Fail before. I ran the new tests against main's
validator.go, withoutkidguard.go:Pass after. On this branch,
go test -race -count=5 ./internal/server/scheduledauth/passes, and so doesgo test -count=1 ./internal/server/ ./internal/mocks/.New tests in
kidguard_test.go:-race: 16 workers × 25 rounds of mixed valid and unknown-kid tokens. Valid tokens always pass and there are at most 6 fetches.Mutation probes. Each probe was applied to
kidguard.go, the package tests were run, and the file was restored:FailedRefreshDoesNotCacheKid, the existingKeyRotation_StaleInflightNegativeKidBypassedOncePublished,ForgedPublishedKidDoesNotBlockLegit,IsSignatureError_MatchesGoOIDCContractNegativeKidExpiresAfterTTL, the existingRotationRecoversAfterIntervalNegativeKidCache_BoundedAndExpiringRepeatedUnknownKid,ExpiresAfterTTL,ForgedPublishedKid,NegativeKidConcurrent,RebuildRateLimitedExisting test change.
TestVerifyRetry_RebuildRateLimitedasserted at least 2 JWKS fetches. It now asserts exactly 1: the rebuild retry and the second token are answered from the negative cache, so they no longer fetch. This is the intended effect, and the new assertion is stricter.Static checks:
go build ./...andgo vet ./internal/server/...pass.gocyclo -over 10is silent.scripts/test-git-secrets-allowlist.shreports 42 PASS.Coverage gap. Nothing was exercised against Google's real JWKS endpoint or a real Cloud Scheduler token.
Out of scope / notes
KeyRotation_RefreshOnUnknownKidtest asserts that this does not happen), or per-source rate limiting at the edge. Left for a maintainer decision.validator.gowas already over the 500-line guideline (543 lines). It is now 550, and the new logic lives in its own file.configureOIDCinvalidator.go(https-only JWKS URL). The hunks look separate, but whichever PR merges second may need a trivial rebase.Maintainer: please mirror the issue's triage labels onto this PR:
impact/internal,triaged,priority/p3,severity/low,urgency/this-quarter,effort/s,type/security.🤖 Generated with Claude Code