fix(auth): reuse the remote JWKS key set and bound every auth fetch - #41
Merged
Conversation
`authenticate()` called `createRemoteJWKSet()` on every request. That resolver is stateful — it owns the cached keys, the cooldown window and the coalescing of concurrent refreshes — so building a new one per request discarded all of it. Every authenticated request therefore made its own round trip to the identity provider. In production this saturated the outbound connection pool of the long-running process. `GET /api/v1/pathways` on data-pathways-control-plane returned 401 on 6 of 6 attempts, each at exactly 5.00 s, with `ERR_JWKS_TIMEOUT` in the logs — while the same JWKS URL answered in 3 ms from a fresh process in the same pod. The service sat at 23m CPU against a 1-core limit. It was blocked, not busy. Changes: - Cache remote key sets in a module-level Map, keyed by URL and timeout. jose keeps handling rotation via `cooldownDuration` (30 s) and `cacheMaxAge` (10 min). - Pass an explicit `timeoutDuration` instead of relying on the implicit default. - Bound both api-key validation fetches with `AbortSignal.timeout`. They had no timeout at all, so a stuck upstream held the caller until its own deadline and queued every request behind it. - Include the upstream status, or the timeout, in the thrown error. `AppExceptionUnauthorized()` with no message gave the operator nothing. - Expose `auth.jwks_timeout_ms` and `auth.api_key_timeout_ms` on `HonoApi`, both defaulting to 5000 ms. Tests generate a real RS256 key pair, serve a real JWKS document and count fetches. Reverting only the reuse line fails two of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
authenticate()built a new JWKS resolver on every single request:createRemoteJWKSetis stateful. The returned resolver owns the cached keys, the cooldown window, and the coalescing of concurrent refreshes. Constructing one per request discards all three, so every authenticated request made its own round trip to the identity provider.Separately, both api-key validation calls used a bare
fetchwith no timeout.Production impact
Found while investigating the Flowcore UI failing to list data pathways on 2026-09-01.
GET /api/v1/pathwaysondata-pathways-control-plane: 6 requests, 6 failures, each returning 401 at exactly 5.00 s.Every one is a root span with no children — jose's internal fetch is not instrumented. The pod log names it:
5000 ms is jose's default
timeoutDuration, which matches the measured latency to three decimals.The endpoint itself was never slow. Probing the same URL from a fresh process inside the same failing pod:
Ruled out by direct measurement:
The process was fast on every path that skipped JWT verification and stalled only on the path that entered
jose. That is a saturated outbound connection pool in the long-running process — the direct consequence of one uncached HTTPS round trip per request.Changes
Map, keyed by URL and timeout. Rotation is still handled by jose throughcooldownDuration(30 s) andcacheMaxAge(10 min), so an unknownkidstill triggers a refetch.timeoutDurationexplicitly rather than relying on the implicit default.AbortSignal.timeout. An unbounded fetch on the auth path is not a slow request, it is a stuck one — the caller holds its slot until its own deadline and every later request queues behind it.AppExceptionUnauthorized()with no message gave operators nothing to work with.auth.jwks_timeout_msandauth.api_key_timeout_msonHonoApi, both defaulting to 5000 ms.Tests
test/authenticate-jwks.test.tsgenerates a real RS256 key pair, serves a real JWKS document, signs a real token, and counts fetches:The tests have teeth. Reverting only the reuse line and re-running:
Full suite,
deno fmt,deno lintanddeno task typecheckare all clean:Backward compatibility
No breaking change.
requestOptionsis a new optional trailing parameter, and both timeouts default to 5000 ms — which is what jose already used for JWKS. The api-key calls gain a bound they previously lacked, which is the point.Follow-up, not in this PR
The library's built-in auth defaults are all public hostnames:
For in-cluster consumers every one of these hairpins out through the public ingress to reach a peer a namespace away.
service-security-organization-api-key-api#18fixed exactly this for the tenant store and measured the same call at 25 ms in-cluster versus 277 ms public. These defaults are the systemic source of that pattern, but changing them would break out-of-cluster consumers, so it needs its own decision.🤖 Generated with Claude Code