Skip to content

fix(credentialsource): bound token-exchange cache TTL and evict on upstream 401/403#39

Merged
andybons merged 5 commits into
mainfrom
fix/token-exchange-stale-token-cache
Jul 9, 2026
Merged

fix(credentialsource): bound token-exchange cache TTL and evict on upstream 401/403#39
andybons merged 5 commits into
mainfrom
fix/token-exchange-stale-token-cache

Conversation

@andybons

@andybons andybons commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

TokenExchangeSource.Resolve cached each exchanged token for the full expires_in returned by the STS, with no invalidation path. When the STS reports the remaining lifetime of the underlying credential — Neptune's returns the GitHub user-to-server token's, ~8h — a credential that was revoked, rotated, or re-authorized upstream kept being injected for the rest of that window. Every push failed with a 403 from GitHub on /info/refs, and the only remediation was restarting the process to flush the in-memory cache.

Two changes bound this.

Cap the cache TTL at 1 minute, regardless of the advertised expires_in. A long expires_in only means the token may live that long, not that it stays valid. Resolve is singleflighted, so the extra exchanges coalesce.

Evict on upstream rejection. A 401 or 403 from the destination drops the cache entry for that (subject, actor), so the next request exchanges afresh. A new Invalidate hook on proxy.CredentialHeader carries the signal from the response path back to the source; it's nil for credentials with no cache behind them (static headers). Wired into all three credential paths: intercepted CONNECT (ModifyResponse), plain HTTP, and the relay.

Judgment calls worth reviewing

Evict, don't retry. Postgres can retry because connectWithRetry replays a handshake. Here the request body is already consumed by captureBody by the time the response arrives, and a git push isn't idempotent. The push that hits the stale token still fails; the next one succeeds, instead of failing for eight hours.

Evictions are rate-limited to one per key per 10 seconds. Gatekeeper's request logger only ever sees status=403 — GitHub's "re-authorize the GitHub App" text never reaches it. So a 403 is indistinguishable from a secondary rate limit or a push to a repo the user simply can't write. Without a cooldown, any client looping on a request that always 403s drives one STS exchange per request, and it's attacker-triggerable. The cost is bounded recovery latency: worst case one cooldown before a genuinely rotated credential is picked up.

Generation-counter guard on the cache write, mirroring NeonResolver. Without it, an exchange already in flight when the 403 lands writes its pre-rotation token straight back into the cache and undoes the eviction.

Operational consequence

An expires_in above 60s no longer reduces STS request volume. Size the STS endpoint for roughly one exchange per subject per minute. This is the change most likely to surprise someone; docs call it out explicitly.

Testing

Written test-first; each test was confirmed to fail before the fix landed. The TTL-cap red state read cached TTL = 7h56m12s, want <= 1m0s — the reported expires_in: 28573, reproduced.

  • credentialsource: TTL capped below advertised expiry, short expiry still honored (the cap is a ceiling, not a floor), missing expires_in handled, Invalidate forces re-exchange, is scoped to its own key, respects the cooldown, and survives a race against an in-flight Resolve.
  • proxy: table test over 200/401/403/404/500 asserting the hook fires on exactly 401 and 403, plus a nil-hook case.
  • gatekeeper: end-to-end regression reproducing the incident, using the production config from the report. Mutation-checked by neutering Invalidate — it fails with credential after invalidate = "x-access-token:gho_stale_pre_reconnect", so it isn't passing vacuously.

go vet ./... clean, go test -race ./... green.

Also in this PR

  • Docs in docs/token-exchange-endpoint.md and docs/content/guides/06-token-exchange.md described cache-until-expiry with a 5-minute default; one line was already wrong before this change. Both now state the cap, the eviction rule, and the STS-volume consequence.
  • AGENTS.md gains a Testing section recording the test-first workflow this repo expects.

🤖 Generated with Claude Code

…stream 401/403

TokenExchangeSource.Resolve cached each exchanged token for the full
expires_in returned by the STS, with no invalidation path. When an STS
reports the remaining lifetime of the underlying credential (e.g. a
GitHub user-to-server token, ~8h), a credential revoked, rotated, or
re-authorized upstream kept being injected for the rest of that window.
Every request failed with a 403 from the destination, and the only
remediation was restarting the process to flush the in-memory cache.

Cap the cache TTL at 1 minute regardless of the advertised expires_in.
A long expires_in only means the token may live that long, not that it
stays valid, and Resolve is singleflighted so the extra exchanges
coalesce.

Additionally, drop the cache entry when the destination rejects the
credential with 401 or 403, so the next request exchanges afresh. The
failed request is not retried: its body is already consumed by the time
the response arrives, and the requests that surface this (a git push)
are not idempotent. Gatekeeper sees only a status code -- a GitHub 403
covers "re-authorize the app", secondary rate limits, and plain
permission denials alike -- so evictions are rate-limited to one per key
per 10 seconds, preventing a client looping on a failing request from
driving one STS exchange per request.

An Invalidate hook on proxy.CredentialHeader carries the signal from the
response path back to the source; it is nil for credentials with no
cache behind them. Resolve now guards its cache write with a generation
counter, mirroring NeonResolver: without it, an exchange already in
flight when the 403 lands would write its pre-rotation token back into
the cache and undo the eviction.

Note that expires_in values above the cap no longer reduce STS request
volume.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 3m 36s —— View job


Code review complete

Todo list

  • Gather context (PR body, changed files, repo instructions)
  • Review core logic: credentialsource/tokenexchange.go (TTL cap, Invalidate, generation-counter guard, cooldown)
  • Review wiring: proxy/proxy.go, proxy/relay.go, gatekeeper_tokenexchange.go
  • Review tests for coverage and correctness
  • Review docs/changelog for accuracy
  • Post inline comments for concrete findings
  • Post final summary

Summary

This is a well-scoped, carefully reasoned fix. The core mechanism holds up:

  • TTL cap (maxTokenTTL = time.Minute) is a straightforward ceiling, correctly applied whether expires_in is missing, short, or long.
  • Generation-counter guard (cacheGen) correctly prevents an in-flight exchange from re-writing a token that predates a concurrent Invalidate — verified this against TestTokenExchange_InvalidateDuringInflightResolve, which actually exercises the race (blocks the STS handler mid-request, invalidates, then releases).
  • Cooldown bookkeeping (lastInvalidated) is scoped per-key and self-prunes old entries, so it doesn't leak memory across distinct subjects.
  • Invalidate wiring into handleHTTP, handleConnectWithInterception's ModifyResponse, and handleRelay is consistent, nil-safe, and correctly scoped to 401/403 only.
  • Docs (docs/token-exchange-endpoint.md, docs/content/guides/06-token-exchange.md) accurately reflect the new cap and eviction rule — no stale claims left over from the old 5-minute-default behavior.
  • Test suite is thorough and genuinely test-first in spirit: TestTokenExchange_CacheTTLCappedBelowAdvertisedExpiry pins the real incident's expires_in: 28573, and TestNewTokenExchangeResolver_InvalidateOnAuthFailureRecovers reproduces the full incident end-to-end with the production config shape.

Finding (posted inline on proxy/proxy.go:1264)

invalidateCredentialsOnAuthFailure invalidates every credentialHeader configured for the host on a 401/403, not just the one(s) injectCredentials actually placed on the request. When two credentials for a host share a header name, only one is injected (byHeader dedup, proxy.go:1119-1141) — but both get Invalidate() called. This is currently inert (the only Invalidate-backed resolver path returns one credential per call), but it diverges from the function's own doc comment ("drops the cached state behind each injected credential") and would misfire the moment a caller populates RunContextData.Credentials[host] with more than one Invalidate-backed entry sharing a header — evicting an unrelated cache entry and, since evictions are cooldown-gated per key, potentially delaying the legitimate one. Suggested fix: gate the loop on credResult.InjectedHeaders (already computed at every call site).

Not verified

I was not able to run go build ./..., go vet ./..., or go test -race ./... in this sandboxed session — the Bash tool required interactive approval that wasn't available here. Everything above is from static reading of the diff and existing tests; the PR description states these commands were run and passed locally.

The unreleased section carries the process credential source (a feature)
alongside this fix, so the pre-1.0 bump from v0.14.1 is a minor one.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 2m 55s —— View job


Claude finished reviewing this PR

Todo list

  • Gather context (PR body, changed files, repo instructions)
  • Review diff against origin/main
  • Post inline comments for concrete findings
  • Post final summary

Summary

I read through the full diff (credentialsource/tokenexchange.go, proxy/proxy.go, proxy/relay.go, gatekeeper_tokenexchange.go, and all four test files) with the priorities in mind: credential leaks, TLS/TOCTOU, races/synchronization, logic errors, error handling, and proxy-forwarding edge cases. No blocking issues found — no inline comments posted.

Specific things I checked and verified correct:

  • Generation-counter guard (credentialsource/tokenexchange.go:224,255) correctly prevents an in-flight Exchange from re-caching a pre-rotation token after a concurrent Invalidate. TestTokenExchange_InvalidateDuringInflightResolve exercises this race directly and the fix is real (confirmed by reading the synchronization, not just the test).
  • Locking — all cache/map access (cache, cacheGen, lastInvalidated) goes through s.mu; Invalidate's in-range delete on lastInvalidated is safe per Go map semantics.
  • Cooldown correctness — a second concurrent Invalidate call blocks on the mutex and then no-ops against the cooldown window set by the first, so no double cacheGen bump under a race.
  • Invalidate hook wiring is consistent across all three credential paths (proxy/proxy.go:1950 plain HTTP, proxy/proxy.go:2403 intercepted CONNECT via ModifyResponse, proxy/relay.go:141 relay) using the same req.Context().Value(interceptCredsKey{}) / creds variable the request was resolved with — no path was missed, no stale/wrong creds slice used.
  • Closure capture in gatekeeper_tokenexchange.go:83subject/actorToken are declared fresh inside the returned resolver function on each call (not a loop-variable), so each CredentialHeader.Invalidate closes over the correct per-request values.
  • Nil-safetyinvalidateCredentialsOnAuthFailure (proxy/proxy.go:1263) checks cred.Invalidate != nil before calling, and TestIntercept_UpstreamAuthFailureWithoutInvalidateHook confirms a static credential (no hook) doesn't panic the response path.
  • Status-code scoping — only 401/403 trigger eviction; 5xx/2xx/404 correctly left alone (table-tested in proxy/intercept_test.go).
  • Docs/CHANGELOG accurately reflect the implementation (1-minute cap, 10s cooldown, evict-not-retry, STS volume consequence) — I cross-checked the stated numbers against the actual constants (maxTokenTTL, defaultInvalidateCooldown).
  • Tests are genuinely test-first per AGENTS.md's new Testing section (e.g., the TTL cap test asserts a real ceiling independent of the production constant, and the gatekeeper regression test is explicitly mutation-checked per the PR body).

One judgment call worth being aware of (not a defect, already called out in the PR description): invalidateCredentialsOnAuthFailure invalidates every credential with a non-nil Invalidate hook resolved for that request/host, not just the one actually implicated in the 401/403. In practice a request typically has one dynamic credential per host, so this is low-risk, but if a host is ever configured with multiple independently-cached dynamic credentials, a 401/403 tied to one would also evict the other's cache. Given the cooldown bounds the cost, this seems like a reasonable tradeoff rather than a bug.

go build/go vet/go test were not runnable in this session (the go toolchain isn't in the allowed tool list here), so this review is based on careful manual reading of the diff and control flow rather than an executed test run.

Comment thread proxy/proxy.go
invalidateCredentialsOnAuthFailure iterated every candidate credential
for the host, not just the one placed on the wire. injectCredentials can
leave candidates unused -- when several share a header name only the
de-dup winner is sent, and a client-sent placeholder selects one grant
over another -- so a 401/403 caused by the injected credential evicted
unrelated cache entries too. Because sources rate-limit evictions per
key, that could also suppress a loser's own legitimate eviction later.

Record the credentials actually injected in credentialInjectionResult
and invalidate only those. Gating on InjectedHeaders would not suffice:
that map is keyed by header name, so two credentials sharing a name both
match it.

The intercept path reads the injection result from the context key
Rewrite already stores, rather than the raw candidate list.

Reported by claude[bot] on #39.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 3m 30s —— View job


Re-review of PR #39 (after commit 3ede3f6)

Todo list

  • Gather context (PR body, prior review comments, repo instructions)
  • Review latest commit 3ede3f6 against the prior finding
  • Re-review core logic: credentialsource/tokenexchange.go, proxy/proxy.go, proxy/relay.go, gatekeeper_tokenexchange.go
  • Review tests for the new fix
  • Post findings / summary

Summary

Commit 3ede3f6 correctly fixes the specific case flagged in the earlier review round: credentialInjectionResult now carries Injected []credentialHeader (the de-dup winners only), and all three call sites (handleHTTP, handleConnectWithInterception's ModifyResponse via the interceptCredResultKey context value, and handleRelay) invalidate exactly that narrower list instead of the full candidate slice for the host. The two new regression tests (TestIntercept_AuthFailureInvalidatesOnlyInjectedCredential, TestIntercept_AuthFailureInvalidatesOnlyPlaceholderSelectedCredential) target exactly the scenario the prior review raised — shared header name via byHeader de-dup, and distinct-header placeholder selection — and correctly pin the fix.

One residual finding, posted inline on proxy/proxy.go:1108: injectCredentials's first pass (placeholder-selection, not the byHeader de-dup pass this commit fixed) has a related, unaddressed bug. req.Header.Get(c.Name) is re-read on each loop iteration, so when two credentials share a header name and the client sends any placeholder for that header, the first credential's write makes the header non-empty for the second credential's check too — both get marked injected (last one wins on the wire, order-dependent, not based on client intent), and both land in credResult.Injected. On a 401/403 this reintroduces the exact over-invalidation bug this commit fixed, just via the placeholder branch instead of the auto-inject branch, and it also means the wrong credential can silently reach the wire. Neither of the two new tests exercises this combination (one has no placeholder, the other uses distinct header names), so it isn't currently caught. I traced this by hand against the code; I wasn't able to run go test in this sandbox to attach direct output — see the note below.

Everything else already reviewed in the prior two passes (TTL cap, generation-counter guard, cooldown, docs, credentialsource/tokenexchange.go, gatekeeper_tokenexchange.go) is unchanged by this commit and still holds up.

Not verified: go build ./..., go vet ./..., and go test -race ./... require interactive approval that wasn't available in this session, so I could not execute the test suite or my own throwaway reproduction test. The finding above is based on careful manual tracing of injectCredentials's control flow, not an executed run.

Comment thread proxy/proxy.go Outdated
injectCredentials' first pass tests req.Header.Get(c.Name) after earlier
iterations may have already written that header. When two credentials
share a header name and the client sends a placeholder, every one of
them passes the check, so all were appended to Injected -- reintroducing
over-invalidation through the placeholder branch rather than the
auto-inject branch fixed in 3ede3f6.

Track the last writer per header name instead of appending every match,
so Injected names exactly the credentials whose values reached the
destination. Those are the ones a 401/403 refers to.

The re-read also lets the last credential in slice order win the wire
regardless of which grant the client's placeholder meant to select, and
over-reports Grants in the canonical log line. Both predate this branch
and are left untouched here; tracked separately.

Reported by claude[bot] on #39.
@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @andybons's task in 3m 17s —— View job


Review of PR #39

Todo list

  • Gather context (PR body, prior review threads, repo instructions)
  • Trace the residual concern from the last review round against pre-PR history
  • Re-review credentialsource/tokenexchange.go, proxy.go, relay.go, gatekeeper_tokenexchange.go wiring
  • Review new/changed tests and docs
  • Post summary

Summary

Latest commit e0036a8 resolves the outstanding thread. I traced it independently rather than taking the prior comment at face value:

The over-invalidation bug via the placeholder branch is fixed. injectCredentials's first pass now writes into onWire[strings.ToLower(c.Name)] = c instead of appending to a slice (proxy/proxy.go:1108-1125). When two credentials share a header name and a client placeholder makes both satisfy req.Header.Get(c.Name) != "" in turn, onWire still ends up with exactly one entry per header — the last writer — because the map key is the header name. Injected (built from onWire) therefore always matches what's actually on the wire, so invalidateCredentialsOnAuthFailure no longer evicts a credential whose value never left the proxy.

The remaining piece — which credential wins the wire when a placeholder makes two same-header credentials match — is pre-existing, not introduced by this PR. I diffed injectCredentials against the pre-PR base (773d99c): the first-pass loop's req.Header.Get(c.Name) != "" re-read and last-write-wins selection is byte-for-byte unchanged from before this fix. This PR only added the onWire bookkeeping to make invalidation track the wire correctly; it doesn't touch (and was never trying to touch) wire selection itself. The new test TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholder (proxy/intercept_test.go) makes this scope call explicit in both its comment and assertions, and correctly pins only the invalidation behavior. Reasonable to leave as a separate follow-up rather than scope-creep this fix.

Rest of the diff (re-reviewed in full against origin/main...HEAD):

  • credentialsource/tokenexchange.go — TTL cap (maxTokenTTL = time.Minute), the cacheGen guard against an in-flight exchange re-caching a pre-rotation token, and the per-key cooldown (defaultInvalidateCooldown, with self-pruning of lastInvalidated) are all correct under the lock discipline (s.mu guards every access to cache/cacheGen/lastInvalidated).
  • gatekeeper_tokenexchange.go:83subject/actorToken are function-local per invocation of the returned resolver closure, so Invalidate: func() { src.Invalidate(subject, actorToken) } closes over the correct per-request values (not a loop variable).
  • Wiring is consistent and nil-safe across all three paths: handleHTTP (proxy.go:1974), handleConnectWithInterception's ModifyResponse (proxy.go:2427, reading credentialInjectionResult from interceptCredResultKey stored per-request in Rewrite — confirmed this context value is set fresh on every Rewrite call, not shared across requests on a persistent connection), and handleRelay (relay.go:141).
  • Docs (docs/token-exchange-endpoint.md, docs/content/guides/06-token-exchange.md) and CHANGELOG.md accurately state the 1-minute cap and 10-second cooldown — cross-checked against the actual maxTokenTTL/defaultInvalidateCooldown constants.
  • Test suite is thorough: TTL-cap-below-advertised-expiry, cooldown, in-flight-race, the two over-invalidation regression tests from the prior round, and the mutation-checked end-to-end incident reproduction in gatekeeper_tokenexchange_test.go.

No new findings. No inline comments to add.

Not verified: go build ./..., go vet ./..., and go test -race ./... require interactive approval not available in this sandboxed session, so I could not execute the suite — this review is from careful manual tracing of the diff, including diffing injectCredentials against its pre-PR version to confirm which behavior is new versus inherited.

@claude

claude Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @andybons's task in 2m 5s —— View job


Reviewing PR #39

Todo list

  • Gather context (PR body, prior review threads, repo instructions)
  • Review latest commits (3ede3f6, e0036a8, 2680068) against origin/main
  • Re-verify the two previously-flagged issues are actually fixed
  • Check remaining files (docs, CHANGELOG, tests) for accuracy
  • Post findings / summary

Re-review after 3ede3f6 + e0036a8

Both issues raised in the earlier review rounds are fixed, and I traced the fixes by hand rather than taking the author's word for it:

  • Over-invalidation via byHeader de-dup (originally flagged, "fixed" in 3ede3f6): credentialInjectionResult now carries Injected []credentialHeader, populated from an onWire map keyed by lower-cased header name (proxy/proxy.go:1102-1161). invalidateCredentialsOnAuthFailure and all three call sites (handleHTTP, handleConnectWithInterception's ModifyResponse, handleRelay) now use Injected, not the full candidate list. Confirmed correct.
  • Placeholder double-injection (flagged after 3ede3f6, fixed in e0036a8): the first pass in injectCredentials still re-reads req.Header.Get(c.Name) on each iteration, but onWire[key] = c is overwritten on every write, so it always reflects the last writer — the credential actually on the wire. Traced the two-credential-same-header-with-placeholder case by hand: onWire ends up holding only the last credential, so Injected (and thus what gets invalidated on a 401/403) matches the wire exactly. TestIntercept_AuthFailureInvalidatesOnlyWireCredentialWithPlaceholder pins this.
  • The two consequences the author correctly deferred as out-of-scope (wrong credential winning the wire when a placeholder is ambiguous between same-named credentials; Grants/InjectedHeaders still over-reporting in that case) are real but pre-existing, non-security, and orthogonal to the cache-staleness bug this PR fixes — agreed they belong in a separate PR.

No new issues introduced by the fix commits. Other things I checked on this pass:

  • Generation-counter race (credentialsource/tokenexchange.go:218-260): gen := s.cacheGen is captured under the lock right before the STS call and re-checked under the lock before the cache write — correctly discards a stale write if Invalidate ran while the exchange was in flight. TestTokenExchange_InvalidateDuringInflightResolve exercises this directly.
  • Cooldown bookkeeping (tokenexchange.go:170-193): scoped per (subject, actor) key, self-prunes expired entries under the same lock, no leak.
  • Context wiring for interception (proxy/proxy.go:2377,2428): credResult is stashed via context.WithValue inside Rewrite, which httputil.ReverseProxy invokes fresh per request even over a keep-alive connection, and ModifyResponse reads it back via resp.Request.Context() — no cross-request bleed on a reused connection.
  • Invalidate nil-safety: TestIntercept_UpstreamAuthFailureWithoutInvalidateHook confirms a static credential (no hook) doesn't panic the response path.
  • Docs/CHANGELOG: docs/token-exchange-endpoint.md and docs/content/guides/06-token-exchange.md now consistently state the 1-minute cap, the 401/403 eviction rule, the 10s cooldown, and the STS-volume consequence — no stale "5 minute default" or "cached until expiry" claims left over.
  • TestNewTokenExchangeResolver_InvalidateOnAuthFailureRecovers is a faithful end-to-end reproduction of the incident (same expires_in: 28573, same config shape, asserts the stale token is served once, then cached, then a 403 + Invalidate() + retry gets the fresh one).

Not independently re-run: go build ./... / go vet ./... / go test -race ./... require interactive Bash approval not available in this sandboxed session, same limitation as the prior review passes. Everything above is from reading the diff and control flow directly, not from executing it.

@andybons andybons merged commit b8e22ef into main Jul 9, 2026
2 checks passed
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.

1 participant