Skip to content

Release per-issuer JWKS refresh workers on auth-server shutdown - #6493

Open
tgrunnagle wants to merge 4 commits into
mainfrom
furtive-appendix
Open

Release per-issuer JWKS refresh workers on auth-server shutdown#6493
tgrunnagle wants to merge 4 commits into
mainfrom
furtive-appendix

Conversation

@tgrunnagle

Copy link
Copy Markdown
Collaborator

Summary

MultiIssuerTokenValidator starts one jwk.Cache per configured trusted issuer, each running an httprc worker pool (~3 goroutines) rooted at context.Background(). Nothing ever released them: neither Server.Close() nor CloseIdleConnections reached the validator, and jwk.Cache.Shutdown was 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.

  • Why: each auth-server reconstruction abandoned every issuer's JWKS refresh worker pool for the life of the process, and a reconstruction that failed after buildUpstreams but during buildProvider abandoned a half-built validator that no deferred cleanup covered.
  • What: the validator now owns a scoped context that roots every per-issuer cache, exposes Close() to tear them all down, and is held on server so both normal shutdown and the failed-reconstruction path release it.

Closes #6482

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Manual testing (describe below)

Tests for pkg/authserver and pkg/authserver/server/tokenexchange pass under -race. The new Close() unit test is deterministic: jwk.Cache.Shutdown returning nil proves its workers drained within the timeout. A server-wiring test confirms the validator is built, held on server, and shut down by Close().

Changes

File Change
pkg/authserver/server/tokenexchange/multi_issuer_validator.go Add a validator-scoped context.CancelFunc; root each per-issuer jwk.Cache in that context; add Close() (cancel first, then drain each cache via Shutdown) and shutdownJWKSCache(); clean up already-started caches on partial construction failure.
pkg/authserver/server_impl.go Hold the validator on server; build it eagerly in buildProvider and return it; call Close() from server.Close() and newServer's deferred error path; join shutdown and storage-close errors.
pkg/authserver/server.go Update the Scope: note on CloseIdleConnections to explain it deliberately does not release the JWKS workers.
pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go Close() unit test; t.Cleanup in the newMultiValidator test helper.
pkg/authserver/server_test.go Server-wiring test covering validator ownership and shutdown.

Does this introduce a user-facing change?

No.

Implementation plan

Approved implementation plan
  1. Add a validator-scoped context.CancelFunc field to MultiIssuerTokenValidator; root each per-issuer jwk.Cache in that context; add Close() that shuts down each cache and cancels the context; add construction-error cleanup; update stale comments.
  2. Hold the validator on server; have buildProvider build it whenever trusted issuers exist and return it; call Close() from server.Close() and newServer's deferred error path.
  3. Update the Scope: comment on CloseIdleConnections.
  4. Tests: Close() unit test (deterministic — Shutdown returning nil proves workers drained) plus a server-wiring test.

Special notes for reviewers

  • Cancel-first, then drain: Close() cancels the shared context up front so a slow-draining cache is already unwinding by the time its Shutdown is reached — Close does not serialize on each pool's full timeout. shutdownJWKSCache passes a fresh timeout context to Shutdown (not the cancelled validator-scoped one), or httprc's Controller.ShutdownContext would return immediately without waiting.
  • CloseIdleConnections intentionally does not release the workers: that path must stay safe on a live server, which needs the workers to keep refreshing external issuers' keys. Its Scope: doc note was updated to say so.
  • Out of scope: pkg/auth/token.go's TokenValidator has 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

tgrunnagle and others added 2 commits September 2, 2026 11:11
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>
@github-actions github-actions Bot added the size/M Medium PR: 300-599 lines changed label Sep 2, 2026
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.60274% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.23%. Comparing base (2ad87a3) to head (8a7e7bd).

Files with missing lines Patch % Lines
pkg/authserver/server_impl.go 62.16% 14 Missing ⚠️
...ver/server/tokenexchange/multi_issuer_validator.go 85.18% 4 Missing ⚠️
...hserver/server/tokenexchange/jwt_bearer_handler.go 33.33% 2 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread pkg/authserver/server/tokenexchange/multi_issuer_validator_test.go
Comment thread pkg/authserver/server/tokenexchange/multi_issuer_validator.go
Comment thread pkg/authserver/server_impl.go
Comment thread pkg/authserver/server_impl.go Outdated
Comment thread pkg/authserver/server/tokenexchange/multi_issuer_validator.go Outdated
Comment thread pkg/authserver/server_impl.go Outdated
tgrunnagle and others added 2 commits September 2, 2026 13:49
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>
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/M Medium PR: 300-599 lines changed labels Sep 2, 2026
@tgrunnagle
tgrunnagle marked this pull request as ready for review September 2, 2026 20:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release per-issuer JWKS refresh workers on auth-server shutdown

1 participant