Skip to content

Commit 5b2599e

Browse files
committed
Cover the auth families: the RFC 9207 iss table, step-up bounds, DCR and refresh
The full iss validation table from the 2026 authorization-response rules: match accepted, trailing-slash difference rejected without normalization (both comparison strings pinned as harness literals so server-side issuer serialization changes cannot invert the test), missing-iss rejected when advertised and tolerated when not, an unadvertised-but-present iss still validated, and an error redirect with a mismatched iss rejected on iss before the missing-code error - the ordering that proves validation applies equally to error responses. Step-up bounds: a second insufficient-scope 403 after one step-up surfaces as an error without another authorize round trip, and a 403 on the GET stream open steps up and reopens with the upgraded token (era-bound: the GET stream is removed at 2026-07-28). DCR defaults (grant_types omitted and passed through verbatim), refresh-token rotation handling at the single-refresh seam (replacement stored, preservation honoured when the server does not rotate), and a non-2xx token response surfacing typed. The as-binding entry splits into its two spec obligations (re-register and no-credential-reuse), both decorating the existing test unchanged. Harness: three small review-approved knobs (iss visibility, code override, persistent step-up shim, non-rotating provider). 16 entries minted (13 tested, 3 deferred), 931 -> 944 cells exact; suite green three runs.
1 parent 05e5928 commit 5b2599e

6 files changed

Lines changed: 851 additions & 17 deletions

File tree

‎tests/interaction/_requirements.py‎

Lines changed: 248 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5363,6 +5363,57 @@ def __post_init__(self) -> None:
53635363
transports=("streamable-http",),
53645364
note="OAuth is HTTP-only.",
53655365
),
5366+
"client-auth:stepup:retry-cap": Requirement(
5367+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
5368+
behavior=(
5369+
"Step-up re-authorization is bounded per request send: one re-authorization and one "
5370+
"retry, after which a further insufficient_scope 403 on the retried request "
5371+
"surfaces to the caller as an error without another authorization attempt."
5372+
),
5373+
transports=("streamable-http",),
5374+
note=(
5375+
"OAuth is HTTP-only. The bound is structural -- the auth flow performs at most one "
5376+
"step-up before its generator ends -- not a configurable retry count; the surfaced "
5377+
"error is the transport's INTERNAL_ERROR stand-in for a non-2xx final response. "
5378+
"Cross-request attempt tracking is the separate deferred "
5379+
"client-auth:stepup:attempt-tracking."
5380+
),
5381+
),
5382+
"client-auth:stepup:get-stream-403": Requirement(
5383+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
5384+
behavior=(
5385+
"A 403 insufficient_scope challenge on the standalone GET stream open receives the "
5386+
"same step-up handling as the POST path: the scope union is re-authorized once and "
5387+
"the stream is established on the retried GET with the upgraded token."
5388+
),
5389+
transports=("streamable-http",),
5390+
removed_in="2026-07-28",
5391+
note=(
5392+
"OAuth is HTTP-only. The standalone GET stream is a 2025-11-25 transport mechanism "
5393+
"removed at 2026-07-28; the auth suite's legacy-mode connect is its natural home. "
5394+
"The uniformity is structural (the OAuth provider wraps every request the transport "
5395+
"issues), but the GET leg's choreography is pinned because a failed step-up there "
5396+
"would otherwise vanish into the stream's silent reconnect loop."
5397+
),
5398+
),
5399+
"client-auth:stepup:attempt-tracking": Requirement(
5400+
source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow",
5401+
behavior=(
5402+
"The client tracks scope-upgrade attempts across request sends to avoid repeated "
5403+
"failures for the same resource and operation combination."
5404+
),
5405+
transports=("streamable-http",),
5406+
note="OAuth is HTTP-only. The per-send bound is client-auth:stepup:retry-cap.",
5407+
deferred=(
5408+
"Not implemented in the SDK: the client OAuth provider keeps no cross-request memory "
5409+
"of scope-upgrade attempts. The 403 insufficient_scope branch "
5410+
"(src/mcp/client/auth/oauth2.py:704-734) performs one inline step-up per send with no "
5411+
"attempt counter and no (resource, operation) key, and OAuthContext (oauth2.py:98) "
5412+
"carries no field recording prior step-up failures, so a second send for the same "
5413+
"resource and operation re-attempts the upgrade unconditionally. The per-send "
5414+
'"repeated 403s do not loop" half of this spec line is client-auth:403-scope-upgrade.'
5415+
),
5416+
),
53665417
"client-auth:as-metadata-discovery:priority-order": Requirement(
53675418
source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery",
53685419
behavior=(
@@ -5462,12 +5513,68 @@ def __post_init__(self) -> None:
54625513
transports=("streamable-http",),
54635514
note="OAuth is HTTP-only.",
54645515
),
5465-
"client-auth:as-binding": Requirement(
5516+
"client-auth:dcr:app-type-heuristic": Requirement(
5517+
source=(
5518+
f"{SPEC_2026_BASE_URL}"
5519+
"/basic/authorization/client-registration#application-type-and-redirect-uri-constraints"
5520+
),
5521+
behavior=(
5522+
"When the client metadata does not set application_type, dynamic client "
5523+
"registration derives it from the redirect URIs: a loopback host or custom URI "
5524+
"scheme yields 'native', otherwise 'web' (SEP-837)."
5525+
),
5526+
added_in="2026-07-28",
5527+
transports=("streamable-http",),
5528+
note=(
5529+
"OAuth is HTTP-only. The spec MUST (always send an application_type) IS satisfied "
5530+
"at this pin: OAuthClientMetadata defaults the field to 'native' and every "
5531+
"registration body carries it, pinned incidentally by the "
5532+
"client-auth:dcr:grant-types-default body snapshot. Only the derive-from-redirect-"
5533+
"URIs strategy for the 'web' SHOULD is unimplemented; a web-app consumer sets "
5534+
"application_type='web' explicitly and it is transmitted verbatim."
5535+
),
5536+
deferred=(
5537+
"Not implemented in the SDK: application_type is a static model default ('native') "
5538+
"on OAuthClientMetadata (src/mcp/shared/auth.py); no code path inspects the "
5539+
"redirect URIs to choose between 'native' and 'web'."
5540+
),
5541+
),
5542+
"client-auth:dcr:grant-types-default": Requirement(
5543+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens",
5544+
behavior=(
5545+
"When the client metadata does not set grant_types, the dynamic-registration "
5546+
"request carries ['authorization_code', 'refresh_token'] so the authorization "
5547+
"server may issue refresh tokens (SEP-2207); a consumer-set grant_types is sent "
5548+
"verbatim, never rewritten."
5549+
),
5550+
added_in="2026-07-28",
5551+
transports=("streamable-http",),
5552+
note=(
5553+
"OAuth is HTTP-only. A SHOULD. Python implements the default on the "
5554+
"OAuthClientMetadata model (a field default), not in registration code, so it is "
5555+
"present from construction -- wire-observably identical to injecting it at "
5556+
"registration time, which is what the registration body pins."
5557+
),
5558+
),
5559+
"client-auth:as-binding:reregister": Requirement(
5560+
source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding",
5561+
behavior=(
5562+
"Stored client credentials are bound to the issuer that registered them; when the "
5563+
"authorization server changes, the client discards them and re-registers with the "
5564+
"new authorization server (SEP-2352)."
5565+
),
5566+
added_in="2026-07-28",
5567+
transports=("streamable-http",),
5568+
note="OAuth is HTTP-only.",
5569+
),
5570+
"client-auth:as-binding:no-cred-reuse": Requirement(
54665571
source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding",
54675572
behavior=(
5468-
"Stored client credentials are bound to the issuer that registered them; when the authorization "
5469-
"server changes, the client discards them and re-registers rather than reusing them (SEP-2352)."
5573+
"When the authorization server changes, the client never reuses credentials from "
5574+
"the previous authorization server: the stale client_id reaches neither the "
5575+
"authorize nor the token endpoint (SEP-2352)."
54705576
),
5577+
added_in="2026-07-28",
54715578
transports=("streamable-http",),
54725579
note="OAuth is HTTP-only.",
54735580
),
@@ -5562,6 +5669,29 @@ def __post_init__(self) -> None:
55625669
transports=("streamable-http",),
55635670
note="OAuth is HTTP-only.",
55645671
),
5672+
"client-auth:refresh:rotation-handling": Requirement(
5673+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens",
5674+
behavior=(
5675+
"On a refresh-token exchange, a new refresh_token in the response replaces the "
5676+
"stored one, and a response that omits refresh_token leaves the stored one in "
5677+
"place -- the client never assumes a refresh token will be issued "
5678+
"(RFC 6749 section 6 / SEP-2207)."
5679+
),
5680+
transports=("streamable-http",),
5681+
note=(
5682+
"OAuth is HTTP-only. No added_in: the replace/preserve mechanics are RFC 6749 "
5683+
"section 6 client behaviour that predates the 2026 Refresh Tokens section restating "
5684+
"them (the add plan classifies this entry era PRE-EXISTING), and the auth tests "
5685+
"bypass the connect fixture so era fields drive no cells. The follow-on claim -- "
5686+
"the NEXT refresh presents the rotated token -- is real-time-bound at this pin: a "
5687+
"token that is already expired when its refresh response arrives is not refreshed "
5688+
"again on the same request; the request goes out unauthenticated and 401s into a "
5689+
"full re-authorization (oauth2.py sends at most one refresh per request and only "
5690+
"attaches a bearer it considers valid), so a second same-connection refresh cannot "
5691+
"be driven without wall-clock waits. The tests therefore pin replacement and "
5692+
"preservation at the storage/wire seam of a single refresh."
5693+
),
5694+
),
55655695
"client-auth:refresh:transparent": Requirement(
55665696
source="sdk",
55675697
behavior=(
@@ -5616,12 +5746,127 @@ def __post_init__(self) -> None:
56165746
transports=("streamable-http",),
56175747
note="OAuth is HTTP-only.",
56185748
),
5749+
"client-auth:iss:match": Requirement(
5750+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5751+
behavior=(
5752+
"When the authorization server's metadata advertises "
5753+
"authorization_response_iss_parameter_supported and the callback's iss equals the "
5754+
"recorded metadata issuer, the client proceeds to redeem the authorization code "
5755+
"(RFC 9207 validation table row 1)."
5756+
),
5757+
added_in="2026-07-28",
5758+
transports=("streamable-http",),
5759+
note="OAuth is HTTP-only.",
5760+
),
5761+
"client-auth:iss:no-normalize": Requirement(
5762+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5763+
behavior=(
5764+
"The iss comparison is simple string comparison (RFC 3986 section 6.2.1): a value "
5765+
"differing from the recorded issuer only by a trailing slash is rejected as a "
5766+
"mismatch -- no scheme or host case folding, default-port elision, trailing-slash, "
5767+
"or percent-encoding normalization is applied before comparison."
5768+
),
5769+
added_in="2026-07-28",
5770+
transports=("streamable-http",),
5771+
note=(
5772+
"OAuth is HTTP-only. The comparison is a single string inequality; the test pins the "
5773+
"trailing-slash arm as the representative normalization class."
5774+
),
5775+
),
5776+
"client-auth:iss:supported-missing-reject": Requirement(
5777+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5778+
behavior=(
5779+
"When the authorization server's metadata advertises "
5780+
"authorization_response_iss_parameter_supported: true and the callback carries no "
5781+
"iss, the client rejects the authorization response before redeeming the code "
5782+
"(RFC 9207 validation table row 2)."
5783+
),
5784+
added_in="2026-07-28",
5785+
transports=("streamable-http",),
5786+
note="OAuth is HTTP-only.",
5787+
),
5788+
"client-auth:iss:unadvertised-proceed": Requirement(
5789+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5790+
behavior=(
5791+
"When the authorization server's metadata does not advertise "
5792+
"authorization_response_iss_parameter_supported and the callback carries no iss, "
5793+
"the client proceeds with the code exchange (RFC 9207 validation table row 4)."
5794+
),
5795+
added_in="2026-07-28",
5796+
transports=("streamable-http",),
5797+
note="OAuth is HTTP-only.",
5798+
),
5799+
"client-auth:iss:unadvertised-present-validated": Requirement(
5800+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5801+
behavior=(
5802+
"A present iss is validated against the recorded issuer regardless of metadata "
5803+
"advertisement (RFC 9207 validation table row 3, where this specification "
5804+
"deliberately exceeds RFC 9207's local-policy provision): a matching iss proceeds "
5805+
"and a mismatching iss is rejected."
5806+
),
5807+
added_in="2026-07-28",
5808+
transports=("streamable-http",),
5809+
note=(
5810+
"OAuth is HTTP-only. Covered by two tests: the match half directly, and the "
5811+
"mismatch half by the client-auth:iss:mismatch-reject test, which drives a "
5812+
"mismatched iss against the suite's unadvertising authorization server."
5813+
),
5814+
),
5815+
"client-auth:iss:error-response-validated": Requirement(
5816+
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
5817+
behavior=(
5818+
"iss validation applies equally to error responses: a mismatched iss on an error "
5819+
"callback is rejected before the flow acts on the response, and on mismatch the "
5820+
"client must not act on or display error, error_description, or error_uri."
5821+
),
5822+
added_in="2026-07-28",
5823+
transports=("streamable-http",),
5824+
note=(
5825+
"OAuth is HTTP-only. The non-surfacing half holds by construction: the callback "
5826+
"contract (AuthorizationCodeResult) carries no error fields, so those values never "
5827+
"enter the SDK; the test pins the observable half -- the iss mismatch is raised in "
5828+
"preference to the missing-authorization-code failure."
5829+
),
5830+
),
56195831
"client-auth:token-endpoint-auth-method": Requirement(
56205832
source="sdk",
56215833
behavior="The client authenticates to the token endpoint using the auth method established at registration.",
56225834
transports=("streamable-http",),
56235835
note="OAuth is HTTP-only.",
56245836
),
5837+
"client-auth:token-error:machine-readable-code": Requirement(
5838+
source="sdk",
5839+
behavior=(
5840+
"An RFC 6749 error response from the token endpoint (e.g. invalid_grant, "
5841+
"invalid_client, on either the authorization-code exchange or a refresh) surfaces "
5842+
"to the caller as a typed OAuth error carrying the wire error code as a "
5843+
"machine-readable field, not only embedded in the message text."
5844+
),
5845+
transports=("streamable-http",),
5846+
note="OAuth is HTTP-only. The weak testable sibling is client-auth:token:error-surfaces.",
5847+
deferred=(
5848+
"Not implemented in the SDK: OAuthTokenError (src/mcp/client/auth/exceptions.py) "
5849+
"carries only a message string; the token-response handler embeds the RFC 6749 "
5850+
"error body in an f-string and the refresh-response handler clears tokens without "
5851+
"reading the body (src/mcp/client/auth/oauth2.py), so there is no machine-readable "
5852+
"error code for a caller to branch on."
5853+
),
5854+
),
5855+
"client-auth:token:error-surfaces": Requirement(
5856+
source="sdk",
5857+
behavior=(
5858+
"A non-2xx response from the token endpoint on the authorization-code exchange "
5859+
"aborts the flow and surfaces to the caller as an error naming the HTTP status; "
5860+
"the flow does not loop, and no request is ever sent with a bearer token."
5861+
),
5862+
transports=("streamable-http",),
5863+
note=(
5864+
"OAuth is HTTP-only. Completes the endpoint error-surfaces family alongside "
5865+
"client-auth:authorize:error-surfaces and "
5866+
"client-auth:dcr:registration-rejected-error; the machine-readable half is "
5867+
"client-auth:token-error:machine-readable-code (deferred)."
5868+
),
5869+
),
56255870
"client-auth:token-provenance": Requirement(
56265871
source=f"{SPEC_BASE_URL}/basic/authorization#token-handling",
56275872
behavior=(

0 commit comments

Comments
 (0)