Release per-issuer JWKS refresh workers on auth-server shutdown - #6493
Release per-issuer JWKS refresh workers on auth-server shutdown#6493tgrunnagle wants to merge 4 commits into
Conversation
MultiIssuerTokenValidator started one jwk.Cache per trusted issuer, each with an httprc worker pool rooted at context.Background() and stopped by nothing — so every auth-server reconstruction in a long-lived process leaked roughly three goroutines per configured issuer, unbounded. This is the goroutine half of #6479; #6480 fixed only the HTTP-connection half. Implements changes for issue #6482: - Add MultiIssuerTokenValidator.Close, which shuts down each per-issuer jwk.Cache (waiting for its workers to drain) and cancels the validator-scoped context the pools are now rooted in - Root the caches in that validator-scoped context instead of context.Background() - Clean up already-started caches on a partial construction failure - Build the shared validator up front in buildProvider whenever trusted issuers exist, return it, and hold it on the server so Close and newServer's deferred error path both release it - Update the CloseIdleConnections Scope note: Close now releases the workers; that function deliberately does not, to stay safe on a live server Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Close the validator in the newMultiValidator test helper via t.Cleanup so the suite no longer leaks a JWKS worker pool per issuer across its call sites (the leak class this change fixes in production) - Cancel the validator-scoped context first in Close, then drain each cache, so a slow-draining pool is already unwinding before its Shutdown is reached and Close no longer serializes on each pool's full timeout; this also matches the construction-failure path's ordering Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #6493 +/- ##
==========================================
+ Coverage 78.18% 78.23% +0.05%
==========================================
Files 769 769
Lines 75049 75087 +38
==========================================
+ Hits 58680 58748 +68
+ Misses 16364 16334 -30
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-agent review — Release per-issuer JWKS refresh workers on shutdown
Recommendation: COMMENT (non-blocking). The implementation is correct and safe to merge as-is. Correctness was independently confirmed by concurrency, security, and Go-correctness specialists across every traced path (fail-closed shutdown, no double-close, no leak path, idempotent, per-issuer SSRF isolation and may_act consent checks unchanged). All actionable findings are about test coverage plus one out-of-diff scope observation — none are defects in the shipped code.
5 specialist reviewers (concurrency & lifecycle, security, Go correctness, test coverage, general quality). Codex cross-review skipped (CLI not installed). 0 HIGH, 3 MEDIUM, 3 LOW.
| # | Severity | Location | Summary |
|---|---|---|---|
| T1 | MEDIUM | multi_issuer_validator_test.go |
Close test's nil-return can't detect a Close that stops draining |
| T2 | MEDIUM | multi_issuer_validator.go |
Partial-construction cleanup loop (#6482's reachable leak) untested |
| C1 | MEDIUM | factory.go (out of diff) |
Exported Factory/JWTBearerIssuanceFactory fallback still builds un-closeable validators |
| A | LOW | server_impl.go |
Dropped _ = cleanup-shutdown errors not logged/commented |
| C2 | LOW | multi_issuer_validator.go |
Sequential per-issuer Shutdown timeouts → worst-case httpTimeout×N |
| T3 | LOW | server_impl.go |
Deferred validator-Close error paths untested |
Highest-value follow-ups: T1 and T2 — this is a leak-prevention change whose value is a test that fails when the leak returns, and neither currently does. A runtime.NumGoroutine() delta or goleak around the Close test and a two-issuer partial-construction case would close both. Neither reflects a defect in the code being merged.
Nice work — the cancel-first-then-drain ordering and the "fresh context or Shutdown returns immediately" handling are exactly right, and building shared eagerly also closes the token-exchange-only leak path as a bonus.
🤖 Multi-agent review via /pr-review
The exported tokenexchange.Factory and JWTBearerIssuanceFactory built their MultiIssuerTokenValidator lazily inside the fosite-compose closure when no shared validator was supplied, storing it only behind an interface with no Close. That validator's per-issuer JWKS refresh worker pools then had no owner able to release them — the same leak #6482 fixes on the server path, still reachable by an external embedder composing a provider through these functions with trusted issuers. The closure cannot hand that instance back (it is built from a config available only at compose time), so require the caller to build it up front via NewSharedTrustedIssuerValidator and pass it in — the only construction path that stays releasable. Fail loudly instead of silently building a leaked one: - FactoryWithSharedTrustedIssuerValidator errors when shared is nil and trusted issuers are configured; the bare Factory (which passes nil) therefore no longer supports trusted issuers standalone - JWTBearerIssuanceFactory requires shared unconditionally (JWT-bearer issuance is meaningful only against trusted issuers) - Both closures drop their lazy-build branches, so no un-closeable validator is ever constructed In-repo callers are unaffected: buildProvider always supplies the shared validator. Rework TestFactory_ValidatorSelection to assert the new fail-closed guard and that the caller-owned shared validator is used verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the remaining review feedback on the shutdown path: - Bound total drain in Close (and the construction-failure cleanup) by a single shared httpTimeout deadline across all issuers, so a set that ignores cancellation cannot make shutdown take httpTimeout×N; the cancel-first ordering already lets the pools unwind in parallel - Log at WARN, rather than silently drop, the validator/cache shutdown errors on the construction error paths (constructor cleanup and newServer/buildProvider defers) — retErr stays the dominant signal but a pool that fails to drain now leaves a diagnostic - Add TestMultiIssuerTokenValidator_CloseReleasesGoroutines: a goroutine count that drops back after Close, catching a regression the nil-return assertion cannot (a Close that stopped draining also returns nil) - Add TestNewMultiIssuerTokenValidator_PartialConstructionDrainsStarted- Pools: two issuers where the second fails in newExternalIssuerConfig, exercising the constructor cleanup loop that drains the first issuer's already-started pool - Note in TestServer_TrustedIssuerValidatorLifecycle that the server-level deferred shutdown paths are covered at the validator level Extract releaseOnConstructionError to keep newServer under the gocyclo ceiling. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
MultiIssuerTokenValidatorstarts onejwk.Cacheper configured trusted issuer, each running anhttprcworker pool (~3 goroutines) rooted atcontext.Background(). Nothing ever released them: neitherServer.Close()norCloseIdleConnectionsreached the validator, andjwk.Cache.Shutdownwas never called. A long-lived process that reconstructs the auth server to change its upstream set therefore leaked one worker pool per trusted issuer on every reconstruction, unbounded. This is the goroutine half of #6479; #6480 fixed the HTTP-connection half.buildUpstreamsbut duringbuildProviderabandoned a half-built validator that no deferred cleanup covered.Close()to tear them all down, and is held onserverso both normal shutdown and the failed-reconstruction path release it.Closes #6482
Type of change
Test plan
task test)task test-e2e)task lint-fix)Tests for
pkg/authserverandpkg/authserver/server/tokenexchangepass under-race. The newClose()unit test is deterministic:jwk.Cache.Shutdownreturningnilproves its workers drained within the timeout. A server-wiring test confirms the validator is built, held onserver, and shut down byClose().Changes
pkg/authserver/server/tokenexchange/multi_issuer_validator.gocontext.CancelFunc; root each per-issuerjwk.Cachein that context; addClose()(cancel first, then drain each cache viaShutdown) andshutdownJWKSCache(); clean up already-started caches on partial construction failure.pkg/authserver/server_impl.goserver; build it eagerly inbuildProviderand return it; callClose()fromserver.Close()andnewServer's deferred error path; join shutdown and storage-close errors.pkg/authserver/server.goScope:note onCloseIdleConnectionsto explain it deliberately does not release the JWKS workers.pkg/authserver/server/tokenexchange/multi_issuer_validator_test.goClose()unit test;t.Cleanupin thenewMultiValidatortest helper.pkg/authserver/server_test.goDoes this introduce a user-facing change?
No.
Implementation plan
Approved implementation plan
context.CancelFuncfield toMultiIssuerTokenValidator; root each per-issuerjwk.Cachein that context; addClose()that shuts down each cache and cancels the context; add construction-error cleanup; update stale comments.server; havebuildProviderbuild it whenever trusted issuers exist and return it; callClose()fromserver.Close()andnewServer's deferred error path.Scope:comment onCloseIdleConnections.Close()unit test (deterministic —Shutdownreturningnilproves workers drained) plus a server-wiring test.Special notes for reviewers
Close()cancels the shared context up front so a slow-draining cache is already unwinding by the time itsShutdownis reached —Closedoes not serialize on each pool's full timeout.shutdownJWKSCachepasses a fresh timeout context toShutdown(not the cancelled validator-scoped one), orhttprc'sController.ShutdownContextwould return immediately without waiting.CloseIdleConnectionsintentionally does not release the workers: that path must stay safe on a live server, which needs the workers to keep refreshing external issuers' keys. ItsScope:doc note was updated to say so.pkg/auth/token.go'sTokenValidatorhas the same shape but already roots its cache in a caller-supplied context; per the issue's "may want to cover both" wording it is left alone here.Generated with Claude Code