feat(client,core): RFC 9207 iss parameter validation on authorization responses (SEP-2468)#2272
Draft
mattzcarey wants to merge 2 commits into
Draft
feat(client,core): RFC 9207 iss parameter validation on authorization responses (SEP-2468)#2272mattzcarey wants to merge 2 commits into
mattzcarey wants to merge 2 commits into
Conversation
… responses (SEP-2468)
- Add authorization_response_iss_parameter_supported to OAuthMetadataSchema
and OpenIdProviderMetadataSchema (RFC 8414 / RFC 9207)
- New exported validateAuthorizationResponseIssuer() implementing the
RFC 9207 Section 2.4 decision table with exact string comparison
- auth() accepts optional iss, validated against the recorded AS metadata
before the authorization code is sent to any token endpoint
- finishAuth(code, { iss }) optional second argument on both
StreamableHTTPClientTransport and SSEClientTransport
- Tests covering all four decision-table rows and the
error-response-mismatch case
Closes #2197
🦋 Changeset detectedLatest commit: 848e052 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
…caller signal The advertised-but-missing rejection (AS metadata sets authorization_response_iss_parameter_supported: true but no iss was supplied) previously fired whenever iss was omitted from auth()/ finishAuth(). The SDK never sees the authorization response itself, so it cannot distinguish 'the response had no iss' from 'the caller did not plumb response parameters through' — and every existing finishAuth(code) caller falls in the second bucket. This broke the client-conformance auth/pre-registration scenario (the fixture AS advertises RFC 9207 support; the harness never passes iss). iss is now tri-state on validateAuthorizationResponseIssuer(), auth(), and both transports' finishAuth(): - string: exact-match validation against the recorded issuer (unchanged) - null: caller asserts it inspected the response and it had no iss -> RFC 9207 fail-closed rejection applies when support is advertised - undefined: caller had no access to response parameters -> validation is skipped entirely Conformance: client suite back to baseline-green (auth/pre-registration 15/15). Client tests: 386 passed.
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.
Note
Draft — this extends the public
finishAuth/auth()API surface and needs maintainer sign-off on shape before polish. Implements part of SEP-2468 (tracking issue #2197).What
Implements RFC 9207 (OAuth 2.0 Authorization Server Issuer Identification)
issparameter validation for authorization responses, per the draft MCP spec (basic/authorization, "Authorization Response Validation"):RFC 9207 §2.4 decision table, as implemented by the new
validateAuthorizationResponseIssuer():authorization_response_iss_parameter_supported: trueississ: null)error/error_descriptionfrom that response either(One extra fail-closed row: if an
issis received but no AS metadata was ever recorded, we reject — there is nothing trustworthy to compare against.)The
issoption is tri-state (string | null | undefined)The SDK never sees the authorization response itself — the caller does. So the strict advertised-but-missing rejection is gated behind an explicit caller signal:
string— theissfrom the authorization response; validated by exact comparison.null— the caller asserts it inspected the authorization response and it contained noiss. This enables the RFC 9207 fail-closed rejection when the AS advertisesauthorization_response_iss_parameter_supported: true.undefined(omitted) — the caller did not have access to the response parameters (every existingfinishAuth(code)caller). Validation is skipped; the SDK cannot distinguish "the response had no iss" from "the caller didn't plumb it", so it does not fail closed on the caller's behalf.An earlier revision of this branch applied the fail-closed rejection whenever
isswas omitted. That broke real flows immediately: the client-conformance harness (like every existing integrator) callsfinishAuth(code)withoutiss, and any AS that advertises RFC 9207 support — including the conformance fixture AS — then failed the entire code exchange (auth/pre-registrationregressed in CI). The tri-state gate keeps the RFC MUST available to callers that can honor it, without breaking callers that cannot.API additions (all optional, backwards-compatible)
@modelcontextprotocol/core:OAuthMetadataSchemaandOpenIdProviderMetadataSchemanow recognizeauthorization_response_iss_parameter_supported(RFC 8414 registered metadata).@modelcontextprotocol/client:validateAuthorizationResponseIssuer(metadata, iss)— throws per the table above;issis the tri-state described above.auth(provider, { ..., iss? })— when anauthorizationCodeis present andissis provided (stringornull), it is validated against the recorded/cached AS metadata beforefetchTokenis called.StreamableHTTPClientTransport.finishAuth(authorizationCode, options?: { iss?: string | null })andSSEClientTransport.finishAuth(...)— optional second argument, plumbed through toauth().Existing callers (
finishAuth(code),auth(provider, { serverUrl, authorizationCode })) keep compiling and behaving identically — including against authorization servers that advertiseauthorization_response_iss_parameter_supported: true.Question for maintainers (open API-shape question)
Should
finishAuthinstead accept the full callback URL (e.g.finishAuth(callbackUrl: URL | string)or an overload), withiss(andcode,state, …) extracted internally? That would make the fail-closed RFC 9207 MUST enforceable by default — the SDK would see the whole authorization response, so "advertised but missing" could always reject — and it is harder to misuse (callers can't forget to plumbiss), at the cost of a bigger API shift. The current shape ({ iss?: string | null }options bag with the tri-state gate above) was chosen as the minimal backwards-compatible extension; thenullsignal exists precisely because the SDK cannot otherwise know the caller saw the response. If the callback-URL shape is preferred, the tri-state collapses naturally (URL present →issparam present-or-absent is known).Caveat: metadata provenance
Validation strength depends on where the recorded issuer comes from. With a provider that implements
discoveryState/saveDiscoveryState, the recorded issuer is the one from the provider's validated AS metadata captured before the redirect — the intended RFC 9207 anchor. Without cached discovery state,authInternalre-discovers metadata at code-exchange time, so the comparison anchor is the freshly-discovered issuer rather than the one recorded pre-redirect. That still defends against mix-up attacks (theissmust match the AS the client is about to use), but recording viadiscoveryStateis the stronger posture and is documented as such.Land order
Intended order: #2265 (SEP-2350) → SEP-2352 draft (
fix/sep-2352-as-binding) → this. All three touchpackages/client/src/client/auth.tsand/or the transport step-up region; this branch is offmain(not stacked), so same-file rebases are expected — there is no hard dependency.Validation
pnpm build:all,pnpm typecheck:all,pnpm lint:all— clean@modelcontextprotocol/clienttests: 386 passed (19 new: all four RFC 9207 table rows under the tri-state semantics, exact-comparison/no-normalization cases, undefined-skips cases at both the helper andauth()/finishAuthlevel, error-response-mismatch case asserting forgederror_descriptioncontent is never surfaced and the code never reaches a token endpoint, plusfinishAuthplumbing success/reject/skip cases on the Streamable HTTP transport)@modelcontextprotocol/coretests: 463 passedpnpm run test:conformance:client:all— baseline-green (auth/pre-registration15/15; theauth/iss-*scenarios remain in the expected-failures baseline because the conformanceeverythingClientdoes not yet plumb the redirect'sissthroughfinishAuth)@modelcontextprotocol/corepatch,@modelcontextprotocol/clientminorDownstream impact
cloudflare/agents calls
transport.finishAuth(code)inpackages/agents/src/mcp/client-connection.ts(~L211-245) — it keeps compiling and behaving identically (theundefinedpath skips validation), but agents won't get iss protection until it plumbs the callback'sissquery param through its OAuth callback handling (client.ts/do-oauth-client-provider.ts); recommend an agents follow-up issue once this lands.Closes #2197