Skip to content

feat(client,core): RFC 9207 iss parameter validation on authorization responses (SEP-2468)#2272

Draft
mattzcarey wants to merge 2 commits into
mainfrom
feat/sep-2468-iss-validation
Draft

feat(client,core): RFC 9207 iss parameter validation on authorization responses (SEP-2468)#2272
mattzcarey wants to merge 2 commits into
mainfrom
feat/sep-2468-iss-validation

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

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) iss parameter validation for authorization responses, per the draft MCP spec (basic/authorization, "Authorization Response Validation"):

Before redirecting the user, the client MUST record the issuer from the validated authorization server metadata (alongside the PKCE verifier and state). Before sending the authorization code to any token endpoint, the client MUST apply the RFC 9207 §2.4 validation rules.

RFC 9207 §2.4 decision table, as implemented by the new validateAuthorizationResponseIssuer():

AS metadata advertises authorization_response_iss_parameter_supported: true Response iss Outcome
yes absent (caller passed iss: null) Reject
yes or no present Exact string compare against recorded issuer (no normalization) — mismatch → Reject
no absent Proceed (validation not possible)
on rejection The client MUST NOT act on error/error_description from that response either

(One extra fail-closed row: if an iss is received but no AS metadata was ever recorded, we reject — there is nothing trustworthy to compare against.)

The iss option 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 — the iss from the authorization response; validated by exact comparison.
  • null — the caller asserts it inspected the authorization response and it contained no iss. This enables the RFC 9207 fail-closed rejection when the AS advertises authorization_response_iss_parameter_supported: true.
  • undefined (omitted) — the caller did not have access to the response parameters (every existing finishAuth(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 iss was omitted. That broke real flows immediately: the client-conformance harness (like every existing integrator) calls finishAuth(code) without iss, and any AS that advertises RFC 9207 support — including the conformance fixture AS — then failed the entire code exchange (auth/pre-registration regressed 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: OAuthMetadataSchema and OpenIdProviderMetadataSchema now recognize authorization_response_iss_parameter_supported (RFC 8414 registered metadata).
  • @modelcontextprotocol/client:
    • New export validateAuthorizationResponseIssuer(metadata, iss) — throws per the table above; iss is the tri-state described above.
    • auth(provider, { ..., iss? }) — when an authorizationCode is present and iss is provided (string or null), it is validated against the recorded/cached AS metadata before fetchToken is called.
    • StreamableHTTPClientTransport.finishAuth(authorizationCode, options?: { iss?: string | null }) and SSEClientTransport.finishAuth(...) — optional second argument, plumbed through to auth().

Existing callers (finishAuth(code), auth(provider, { serverUrl, authorizationCode })) keep compiling and behaving identically — including against authorization servers that advertise authorization_response_iss_parameter_supported: true.

Question for maintainers (open API-shape question)

Should finishAuth instead accept the full callback URL (e.g. finishAuth(callbackUrl: URL | string) or an overload), with iss (and code, 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 plumb iss), 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; the null signal 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 → iss param 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, authInternal re-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 (the iss must match the AS the client is about to use), but recording via discoveryState is 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 touch packages/client/src/client/auth.ts and/or the transport step-up region; this branch is off main (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/client tests: 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 and auth()/finishAuth level, error-response-mismatch case asserting forged error_description content is never surfaced and the code never reaches a token endpoint, plus finishAuth plumbing success/reject/skip cases on the Streamable HTTP transport)
  • @modelcontextprotocol/core tests: 463 passed
  • pnpm run test:conformance:client:all — baseline-green (auth/pre-registration 15/15; the auth/iss-* scenarios remain in the expected-failures baseline because the conformance everythingClient does not yet plumb the redirect's iss through finishAuth)
  • Changeset: @modelcontextprotocol/core patch, @modelcontextprotocol/client minor

Downstream impact

cloudflare/agents calls transport.finishAuth(code) in packages/agents/src/mcp/client-connection.ts (~L211-245) — it keeps compiling and behaving identically (the undefined path skips validation), but agents won't get iss protection until it plumbs the callback's iss query param through its OAuth callback handling (client.ts / do-oauth-client-provider.ts); recommend an agents follow-up issue once this lands.

Closes #2197

… 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-bot

changeset-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 848e052

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Minor

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

@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2272

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2272

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2272

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2272

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2272

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2272

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2272

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2272

commit: 848e052

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement SEP-2468: Recommend Issuer (iss) Parameter in MCP Auth Responses

1 participant