Repository navigation
auth: JWKS refresh cooldown, fetch timeout and shared backoff, operator-configured metadata resource, no-usable-keys 503 (see Consiliency/pmcp#231, Consiliency/pmcp#320) - #325
Merged
Conversation
…operator-configured metadata resource, no-usable-keys 503 S-07: forced (unknown-kid) JWKS refreshes are limited to one per cooldown window (default 10 s). The cooldown is checked before the refresh lock as a fast path and re-checked authoritatively under it, so a concurrent burst of unknown kids shares one forced fetch. The window is stamped only when a fetch is actually attempted. S-08: the JWKS fetch has a total aiohttp ClientTimeout (default 5 s), and a failed refresh opens a shared backoff (default 5 s) so waiters queued behind it fail at once instead of each re-attempting it. A backoff-rejected attempt does not consume the S-07 window. S-10: /.well-known/oauth-protected-resource publishes resource_server_audience, else the normalized metadata URL's origin plus /mcp (netloc kept, so IPv6 brackets survive); never the request Host. A JWKS with no usable keys (PyJWKSetError) now maps to ResourceServerJWKSUnavailable (503) with a fixed, value-free message. See #231 and #320. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
…ckoff, canonical metadata fallback Round-1 review follow-ups on the auth hardening. F1: jwt.decode runs inside a narrow guard (_decode_with_key) that maps PyJWTError (non-InvalidTokenError), TypeError and ValueError from key preparation or verification to invalid_token with fixed text. An unauthenticated alg/key-type mismatch (ES256 against an RSA key) used to escape as a 500. Generic InvalidTokenError descriptions are now fixed text; claim errors whose pyjwt text is fixed keep it. F2/F3: any non-cancellation exception from the JWKS fetch opens the shared backoff and becomes ResourceServerJWKSUnavailable (503); _fetch maps RecursionError from a deeply nested body. Cancellation does not open the backoff (the caller went away, not the endpoint). F4: the metadata resource fallback drops a scheme-default port; a path prefix is not carried over because MCP is served at /mcp (documented). See #231 and #320. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
This was referenced Oct 1, 2026
ViperJuice
added a commit
that referenced
this pull request
Oct 1, 2026
…dler errors described; broken stdio frames never shown (see #297) Answers the round-9 board on #314 @ a449dd9: - codex P1 (blocking): at DEBUG the MCP SDK logs each JSON-RPC message its transports receive and send, after the envelope is accepted and before pmcp validates the payload. install_log_scrubber now makes every rendering of an SDK message object (the four JSONRPC classes, SessionMessage and its metadata) structural, and the scrubber describes dict-shaped messages in log arguments. This takes over the SDK-traffic part of #315. - Found by the new valid-envelope sweep (pre-existing): a request handler's escaping validation error reached the caller as pydantic's text, and a nameless catalog entry was logged by its content. Both are now structural. - claude N1-r9: the stdio reader stays in "describe" mode after a rejected frame (or a frame head behind a banner) until a line parses. - claude N2-r9: banner shapes now described are listed. Repro: 12 of 12 scenarios leak on rev 9, 0 of 12 on rev 10. Main moved to 89559db (#325, #327) during the revision; merged, and the 40 patches are embedded against 89559db and proven cmp-identical to wip/297-code@6078419. Full suite: 5356 passed. Mutants: 64/64 killed; NO_STATIC 62/64 (M22, M35 by design). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF
This was referenced Oct 3, 2026
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.
Implements the merged plan
.consiliency/plans/detailed-231-auth-surface-hardening-20260922-1625.md(#284) for S-07, S-08 and S-10 (see #231), and maps a JWKS with no usable keys to the existing 503 path (see #320). S-09 was already merged in #281 and is not touched here.What changed
src/pmcp/auth.py—AsyncJWKSforced_refresh_cooldown_seconds=10.0,fetch_timeout_seconds=5.0,refresh_failure_backoff_seconds=5.0.get()follows the plan's order. There is a pre-lock fast path (_serve_cache_instead). Then, under_refresh_lock: (1) an authoritative re-check of cache and cooldown; (2) the S-08 backoff gate, which returns the unexpired cache or raisesResourceServerJWKSUnavailableand never stamps the cooldown; (3)_last_forced_refresh = nowif forced, which is the only place it is stamped; (4)_fetch(), where a failure stamps_last_refresh_failureand a success resets it._fetch():aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self._fetch_timeout_seconds))._select_jwk_key:jwt.PyJWKSetError→ResourceServerJWKSUnavailable("JWKS contains no usable signing keys."). The text is fixed, so no pyjwt message, JWKS content or token is ever included. It propagates throughvalidate_resource_server_token'sexcept ResourceServerAuthError: raiseintohandle_mcp's existing 503 branch.src/pmcp/transport/http.py_canonical_resource(). It returnsresource_server_audienceif set; otherwisef"{scheme}://{netloc}/mcp"from the normalizedauth_metadata.protected_resource_metadata_url(its netloc has no userinfo and keeps IPv6 brackets); otherwise"". It never readsrequest.handle_protected_resource_metadatareturns"resource": _canonical_resource().Docs: CHANGELOG
[Unreleased]has three Security bullets (S-07/S-08/S-10) and one Fixed bullet (#320). The README resource-server paragraph and the SECURITY.md async-JWKS sentence are extended, with no[C-NN]citation added.Plan assumptions that changed or were adapted
auth.py. The plan's edits never call that code, so every plan anchor still holds, shifted by a few lines (AsyncJWKSis now atauth.py:382, the metadata handler athttp.py:469). The pyjwt 2.15.1 bump (fix(deps): raise pyjwt to 2.15 for the advisories pip-audit reports against 2.13 #318) is what turns both auth: a JWKS with no usable keys returns 500 instead of the 503 key-set-unavailable response #320 cases intoPyJWKSetError. The floor is>=2.15.0, sojwt.PyJWKSetErroralways exists.elapsed < 2*FTover a 0.2 s sleeping fetch. That depends on timing, and this task requires deterministic concurrency tests. The new test holds the first fetch on anasyncio.Eventuntil all N=5 waiters are queued on the lock, then lets it fail, and assertsfetches == 1. Every fetch is already bounded by the timeout, so the number of fetches is what decides "about one timeout" versus "N × timeout". The plan's mutation (delete the failure stamp) givesfetches=5.fetch_timeout_seconds=0.5with a 5 s outerwait_for, not the default 5 s with an 8 s outer bound, which saves about 5 s per run. A separate assertion pins the default (_fetch_timeout_seconds == 5.0). Thetotal=60mutation still goes red on the outer bound.resourcecomes from the existingresource_server_audience(--oauth-audience/PMCP_OAUTH_AUDIENCE) and falls back to the configured metadata URL's origin. The docs describe that behaviour, and no flag or env var was invented. The three newAsyncJWKSknobs are constructor-only with defaults and are not exposed on the CLI.kidcan match), and the S-07 cooldown limits that to one per 10 s. I mapped the error at the selection site only. Rejecting such a set in_fetchwould also change caching semantics, which goes beyond the issue._make_appintests/test_transport_http.pygained aresource_server_audiencekwarg, an option the plan offers, for the audience-first S-10 test.Tests added
tests/test_auth.py:test_s07_random_unknown_kids_do_not_each_force_a_fetch,test_s07_concurrent_unknown_kids_share_one_forced_refresh,test_s07_s08_recovery_after_failed_forced_refresh_waits_out_the_cooldown[immediate|timeout],test_s07_s08_backoff_rejection_does_not_consume_the_cooldown,test_s08_fetch_is_bounded_by_a_timeout,test_s08_concurrent_get_bounds_the_last_waiter,test_jwks_no_usable_keys_is_unavailable_not_a_crash[empty|malformed_only].tests/test_transport_http.py:test_s10_metadata_resource_does_not_reflect_forged_host,test_s10_metadata_resource_uses_configured_audience,test_s10_metadata_resource_fallback_keeps_ipv6_brackets.tests/test_auth_origin_wiring.py:TestJWKSWithoutUsableKeys::test_jwks_no_usable_keys_is_503_value_free[empty|malformed_only]. This test exercises the realAsyncJWKSand validator path end to end, with only_fetchpatched. It asserts 503,error="temporarily_unavailable", and that the response body, headers and captured DEBUG logs contain no JWKS content and no token.--collect-only | grep -E "s07|s08|s10"gives the plan's 10 node ids.asyncio.Eventplussleep(0)drains, or a fake clock patched ontopmcp.auth.time.Measured mutations
Each mutation was run the same way: save a copy, apply only the mutation, check
git diff --stat(against the committed fix), run the named tests, restore from the saved copy, thencmp(identical every time) and confirmgit diff --statis empty.git diff --stat_last_forced_refresh = nowstampauth.py | 2 +-unknown kids each forced a fetch: calls=3None if force_refresh else self._cached(now)(pre-lock check left alone)auth.py | 2 +-concurrent unknown kids amplified the refresh: fetches=6 (1 initial + 5 forced for 5 concurrent requests); sequential S-07 test green (1 failed, 1 passed), which is why both tests exist_fetch()auth.py | 4 ++--[immediate]red:a forced fetch ran at t=9.0s, before max(C, d+B)=10.0s: fetches=2;[timeout]green (1 failed, 1 passed), as the plan predictsClientTimeout(total=60)auth.py | 2 +-asyncio.exceptions.TimeoutErrorfrom the 5 s outerwait_for_last_refresh_failure = time.monotonic()auth.py | 1 -last waiter not bounded: each waiter re-attempted the failed fetch: fetches=5 for 5 waitersauth.py | 2 ++rotated key not accepted at backoff expiry: fetches=1resourceback tostr(request.url_for("mcp"))http.py | 2 +-metadata reflected attacker Host: resource='http://evil.example/mcp','http://evil.example/mcp' == 'https://canonical.example/mcp',fallback resource wrong: resource='http://evil.example/mcp'hostname/porthttp.py | 2 +-fallback resource wrong: resource='https://2606:4700::1:8443/mcp'PyJWKSetErrormappingauth.py | 12 +-----------jwt.exceptions.PyJWKSetError(both cases); HTTP red:assert 500 == 503(both cases)except () as exc)auth.py | 2 +-forged tokens not mapped to invalid_token: ['RS256/kid: TypeError', …, 'ES256/kid: InvalidKeyError', …]; HTTPforged tokens not answered 401: ['ES256/kid: 500', 'ES256/no-kid: 500']InvalidTokenErrordescription back tostr(exc)auth.py | 2 +-token/key content in the error description: ['crit-unknown']; 12 HTTP tests green (12 failed, 12 passed), because the HTTP layer never renders descriptions, so only the unit level catches this oneexcept Exceptioninget()(except () as exc)auth.py | 2 +-a non-JWKSUnavailable fetch failure escaped: ['RecursionError']_fetchJSON parse catches(ValueError,)onlyauth.py | 2 +-RecursionError: maximum recursion depth exceeded while decoding a JSON array from a unicode stringexcept BaseExceptioninget()(cancellation opens the backoff)auth.py | 2 +-a cancelled fetch opened the backoff: waiter got ResourceServerJWKSUnavailable('JWKS refresh recently failed for https://issuer.example/jwks.json; backing off.')pass)http.py | 2 +-fallback resource not canonical: resource='https://gw.example:443/mcp'Round 1 review follow-ups (F1–F4)
F1: a forged token could cause an unauthenticated 500 (this predates the PR, and main has it too). The token's header picks the algorithm and its
kidpicks the key, so a caller can pair any allowed algorithm with any published key. On a mismatch, pyjwt or cryptography raisedTypeError("Expecting a PEM-formatted key."),ValueError, orjwt.InvalidKeyErrorwhile preparing the key.InvalidKeyErroris aPyJWTErrorbut not anInvalidTokenError, so nothing caught any of them.Fix:
jwt.decodenow runs inside_decode_with_key. It letsInvalidTokenErrorthrough to the caller's existing mapping and maps(jwt.PyJWTError, TypeError, ValueError)toResourceServerAuthError("invalid_token", "Token could not be verified with the published key.").Why the boundary is here: the
trywraps exactly one call, and its only variable inputs are the attacker's token and a published key. Key preparation and verification are where pyjwt and cryptography signal "this key cannot verify this token" with these built-in types. ATypeError/ValueErrorfrom PMCP's own code anywhere else (key selection, scope handling, the HTTP handler) still escapes, and so does anything outside that class (AttributeError,KeyError,RecursionErrorand so on). ThePyJWKSetErrormapping from auth: a JWKS with no usable keys returns 500 instead of the 503 key-set-unavailable response #320 stays at the selection site, so a broken key set is still a 503 and not a 401.Also changed: generic error descriptions are now fixed text. For a generic
InvalidTokenError, the description is now"Invalid token.". pyjwt quotes an unknowncritextension name back in its message; the matrix test caught that. Claim errors whose pyjwt text is fixed (expired, not-before, issuer, iat, missing required claim, disallowed algorithm) keep their text, so the existing claim tests are unchanged. The HTTP layer never rendered descriptions anyway; this keeps the exception value-free for anyone embedding the validator.Cases tested. They follow what the code does with token input before and during
jwt.decode(get_unverified_header,kidselection,PyJWKSet,decode):none,NONE, an unknown value and the sentinel), each with and without the key'skid, against RSA, EC P-256, EC P-384, OKP Ed25519 and oct keys, and against a mixed set;kidthat is an int, list or object;alggiven as a list or missing; a 180 KB header;critnaming an unknown extension,critthat is not a list,critwithb64;jku,x5uand an embeddedjwk;test_forged_tokens_are_invalid_token_and_value_free(12 parametrizations, unit level) asserts every case ends ininvalid_tokenand that the sentinel never appears instr(exc)or.description.TestForgedTokenMatrixOverHTTP(12 parametrizations) sends the same cases through the real app,AsyncJWKSand validator, with only_fetchpatched. It asserts every case is a 401 and never a 500. It also asserts that the sentinel (planted in the payload, the signature, header fields and every key'skid) and the token itself never appear in the body, the headers, or logs captured at DEBUG.F2: cancellation. I decided a cancelled fetch does not open the backoff. Cancellation means the caller went away (server shutdown or an embedding caller's timeout), not that the endpoint failed. Opening the backoff would turn one caller's disappearance into 503s for every other request for
Bseconds. State stays consistent:async with;_jwks/_expires_atare untouched;_last_refresh_failureis unchanged;The next waiter fetches. The fetch inside
get()catchesException, neverBaseException, soCancelledErrorpropagates unchanged.test_s08_cancelled_fetch_does_not_open_the_backoffpins this: the waiter gets the fetched keys,fetches == 2,_last_refresh_failure == -inf, and the lock is free. The S-07×S-08 composition is unchanged: the backoff branch still never stamps the cooldown, and M6 is still red.F3: any fetch failure opens the backoff. Besides
ResourceServerJWKSUnavailable,get()'s fetch wrapper now catchesException. It stamps_last_refresh_failureand raisesResourceServerJWKSUnavailable("JWKS fetch failed for <redacted url>.")._fetch's JSON parse now catches(ValueError, RecursionError);ValueErrorcoversJSONDecodeErrorandUnicodeDecodeError.test_s08_any_fetch_exception_opens_the_shared_backoffholds the first fetch and then raises aRecursionErrorcarrying a sentinel. All 5 waiters getResourceServerJWKSUnavailable, withfetches == 1and no sentinel.test_s08_deeply_nested_jwks_body_is_unavailableserves a real 200 KB[[[…]]]body over loopback and expectsInvalid JWKS JSON.F4: S-10 fallback. I chose to make the fallback correct and document the rule, following how the app is mounted.
create_http_approutes MCP at/mcpand the metadata route at the metadata URL's literal path, on the same app. Sohttps://gw.example/tenant/a/.well-known/oauth-protected-resourceis served at/tenant/a/...while MCP is still served at/mcp. A prefix in the metadata URL is therefore not a prefix of MCP, andorigin + /mcpis right; carrying the prefix over would publish a URL the app does not serve. A scheme-default port (:443for https,:80for http) is now dropped, sohttps://gw.example:443/...publisheshttps://gw.example/mcp. The README states the rule and says to set--oauth-audiencewhen MCP is reachable at a different public URL. The new test istest_s10_metadata_resource_fallback_drops_default_port_and_prefix.Gates (read from logs)
Round 1 head (
4083989):uv run ruff check src/ tests/→All checks passed!uv run ruff format --check src/ tests/→171 files already formatteduv run mypy src/→Success: no issues found in 52 source filesuv run pytest tests/test_auth.py tests/test_transport_http.py tests/test_auth_origin_wiring.py --cov-fail-under=0 -q→218 passed in 6.25suv run pytest tests/test_auth.py -k "s07 or s08"×20 →20 × "10 passed, 142 deselected"-m 'not live and not slow'(withnpm_config_cache,npm_config_store_dir,pnpm_config_store_dirunset, run detached on dev0) →4928 passed, 3 skipped, 80 deselected in 740.54s (0:12:20), rc=0python3 scripts/check_security_claims.py→OK ... SECURITY.md: 129 cited node id(s)python3 scripts/check_plan_consistency.py plans/phase-plan-v13-*.md→blocking inconsistencies: 0Initial head (
3c043d7): module tests190 passed in 1.88s; full suite4900 passed, 3 skipped, 80 deselected in 656.73s (0:10:56), rc=0.Do not merge until the panel review is done.
🤖 Generated with Claude Code
https://claude.ai/code/session_01RiabTgWZRfuqiUQ9siEegF