Skip to content

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

Open
mattzcarey wants to merge 9 commits into
mainfrom
feat/sep-2468-iss-validation
Open

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

Conversation

@mattzcarey

@mattzcarey mattzcarey commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

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 records the issuer from validated authorization server metadata alongside the PKCE verifier/state. Before sending an authorization code to any token endpoint, callers that provide authorization-response parameters can apply RFC 9207 Section 2.4 validation.

Decision table implemented by 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 rejects
no absent Proceed
- 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 because 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, matching existing finishAuth(code) callers. Validation is skipped because the SDK cannot distinguish "the response had no iss" from "the caller did not plumb it."

This keeps existing callers compiling and behaving identically while giving hosts that have the callback URL enough API surface to enforce RFC 9207.

API additions

  • @modelcontextprotocol/core: OAuthMetadataSchema and OpenIdProviderMetadataSchema recognize authorization_response_iss_parameter_supported.
  • @modelcontextprotocol/client:
    • New export validateAuthorizationResponseIssuer(metadata, iss).
    • auth(provider, { ..., iss? }) validates iss before fetchToken when authorizationCode is present and iss is provided (string or null).
    • StreamableHTTPClientTransport.finishAuth(authorizationCode, options?: { iss?: string | null }) and SSEClientTransport.finishAuth(...) plumb the optional value through to auth().

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, which is 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 ensures the iss must match the AS the client is about to use, but recording via discoveryState is stronger.

Land order / conformance note

This branch is rebased on origin/main and is not stacked. Because current conformance includes SEP-837 checks, the full client conformance baseline on this branch still depends on #2266 landing first; the observed local failure is DCR application_type specified, not an iss regression. Recommended order: #2265 -> #2266 -> #2271 -> this PR.

The auth/iss-* conformance scenarios remain in the expected-failures baseline because the conformance everythingClient does not yet plumb the redirect's iss through finishAuth.

Validation

After rebasing on current origin/main:

  • pnpm --filter @modelcontextprotocol/client test -- auth.test.ts streamableHttp.test.ts sse.test.ts tokenProvider.test.ts - 388 passed
  • pnpm --filter @modelcontextprotocol/core test - 477 passed
  • pnpm run typecheck:all - clean
  • pnpm run lint:all - clean
  • pnpm run build:all - clean, with the existing AJV/fast-uri missing-export warnings
  • pnpm --filter @modelcontextprotocol/test-conformance exec conformance client --command 'node --import tsx ./src/everythingClient.ts' --scenario auth/metadata-default --expected-failures ./expected-failures.yaml --verbose - fails only on SEP-837 application_type omission, fixed by feat(client,core): application_type client metadata with native/web inference (SEP-837) #2266

Downstream impact

cloudflare/agents calls transport.finishAuth(code) in packages/agents/src/mcp/client-connection.ts, so it keeps compiling and behaving identically through the undefined path. Agents will not get RFC 9207 protection until it extracts iss from the OAuth callback URL in packages/agents/src/mcp/client.ts and forwards it through completeAuthorization() / finishAuth().

Closes #2197

@changeset-bot

changeset-bot Bot commented Jun 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c09f665

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: c09f665

@mattzcarey mattzcarey force-pushed the feat/sep-2468-iss-validation branch from 848e052 to a625f46 Compare June 25, 2026 09:15
@mattzcarey mattzcarey marked this pull request as ready for review June 25, 2026 09:16
@mattzcarey mattzcarey requested a review from a team as a code owner June 25, 2026 09:16
@mattzcarey

Copy link
Copy Markdown
Contributor Author

Rebased on current origin/main, pushed a625f46b, updated the PR body out of draft wording, and marked ready for review.

I checked the implementation against the current upstream spec text for Authorization Response Validation: present iss is compared exactly, advertised-but-missing iss rejects when the caller explicitly passes null, and mismatched issuer errors do not surface forged authorization-response error content.

Verification:

  • pnpm --filter @modelcontextprotocol/client test -- auth.test.ts streamableHttp.test.ts sse.test.ts tokenProvider.test.ts (388 passed)
  • pnpm --filter @modelcontextprotocol/core test (477 passed)
  • pnpm run typecheck:all
  • pnpm run lint:all
  • pnpm run build:all
  • pre-push hook (build, typecheck, lint)

Conformance note: full client conformance is currently blocked on sibling PR #2266 / SEP-837. A verbose auth/metadata-default run fails only on DCR application_type specified; the iss scenarios remain in the expected-failures baseline because everythingClient does not yet plumb callback iss into finishAuth.

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/sse.ts
@mattzcarey

Copy link
Copy Markdown
Contributor Author

Resolved the latest review threads in ede900a.

Changes:

  • Updated docs/client.md to show extracting callback iss and calling finishAuth(code, { iss }), including null vs undefined behavior.
  • Updated simpleOAuthClient and elicitationUrlExample to forward the callback iss into finishAuth.
  • Added SSE finishAuth coverage for mismatched iss, asserting rejection before token exchange.

Verification:

  • pnpm --filter @modelcontextprotocol/client test -- sse.test.ts streamableHttp.test.ts auth.test.ts
  • pnpm run typecheck:all
  • pnpm run lint:all
  • pnpm run build:all
  • pre-push hook passed typecheck/build/lint

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the docs/client.md prose, both runnable examples, and the SSE finishAuth test from my earlier comments are now in the diff, so I have nothing further to flag. Leaving final sign-off to a maintainer since this adds new public API (validateAuthorizationResponseIssuer, the iss option on auth()/finishAuth()) and changes security-relevant OAuth client behavior.

Extended reasoning...

Overview

This PR implements RFC 9207 (SEP-2468) iss-parameter validation for OAuth authorization responses. It adds authorization_response_iss_parameter_supported to the core metadata schemas, a new exported validateAuthorizationResponseIssuer() helper, an optional tri-state iss option on auth(), and an options?: { iss } second argument on both StreamableHTTPClientTransport.finishAuth and SSEClientTransport.finishAuth. Docs, both OAuth examples, and a changeset are updated, and there is substantial new test coverage in auth.test.ts, streamableHttp.test.ts, and sse.test.ts.

Status of prior feedback

My earlier review comments (missing prose docs/example updates, and missing SSE-side finishAuth iss test) are addressed in the current revision: docs/client.md now describes extracting and forwarding iss (including the null vs undefined semantics), simpleOAuthClient.ts and elicitationUrlExample.ts pass { iss } through, and sse.test.ts gained a mismatched-iss rejection test. The validation logic itself matches the RFC 9207 decision table, runs before the code reaches any token endpoint, and uses exact string comparison with no normalization; the mismatch error message does not echo attacker-controlled error_description content, and a dedicated test asserts that.

Security risks

This is security-positive code (mix-up attack mitigation), but it sits squarely in the OAuth client flow. The main design judgment calls a maintainer should own are: (1) the tri-state iss semantics where undefined silently skips validation — sound for backwards compatibility, but it means existing callers get no protection until they opt in; (2) the metadata-provenance caveat the author documents, where without cached discoveryState the comparison anchor is freshly re-discovered metadata rather than the pre-redirect issuer, which weakens the guarantee; (3) new public API surface that is hard to remove later.

Level of scrutiny

High — auth code, new exports across two packages, and a minor version bump for the client. The implementation looks correct and well-tested, but per the repo's own conventions (burden of proof on addition, security-sensitive paths) this should get a human maintainer's review rather than a shadow approval.

Other factors

The bug-hunting system found no bugs in this revision, the changeset text matches the implemented behavior, and the conformance/land-order caveats are clearly documented in the PR description. The cloudflare/agents downstream-compatibility analysis in the description is consistent with the undefined-skips-validation path.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the docs/examples and SSE test gaps from my earlier review are addressed in ede900a, and I have no further findings. Since this adds new public auth API surface (the tri-state iss option, a new export, and finishAuth signature changes) on a security-sensitive path, it should still get a human maintainer's review of the API design rather than an automated approval.

Extended reasoning...

Overview

This PR implements RFC 9207 (SEP-2468) iss parameter validation: a new validateAuthorizationResponseIssuer() export, an optional tri-state iss option on auth(), and an options?: { iss } second parameter on both StreamableHTTPClientTransport.finishAuth and SSEClientTransport.finishAuth. Core's OAuthMetadataSchema/OpenIdProviderMetadataSchema gain authorization_response_iss_parameter_supported. Docs (docs/client.md), both OAuth examples, a changeset, and substantial test coverage (auth.test.ts decision table, StreamableHTTP and SSE transport plumbing tests) are included.

Security risks

This is security-positive in intent (mix-up attack mitigation), but it sits squarely on the OAuth authorization-code exchange path. The validation logic itself looks correct against RFC 9207 Section 2.4 (exact string compare, fail-closed on advertised-but-missing only when the caller asserts null, rejection before any token-endpoint call, no surfacing of forged error params), and the undefined skip path preserves existing behavior for callers that never see the callback URL. The main residual risk is design-level rather than a bug: the protection is opt-in, and the metadata anchor is the freshly re-discovered issuer when no cached discovery state exists, which the PR description itself acknowledges as weaker than the pre-redirect anchor.

Level of scrutiny

High. This touches client auth code, adds three new public API surfaces (export, auth() option, transport method signature), and encodes an API-design decision (tri-state string | null | undefined) that a maintainer should explicitly endorse per the repo's minimalism / burden-of-proof principles. Per my approval criteria, security-sensitive auth changes and new public API design decisions should not be auto-approved regardless of implementation quality.

Other factors

The bug-hunting pass found no defects in the latest revision. My previous review's items (missing prose docs, examples not forwarding iss, missing SSE-side test) were all addressed in ede900a and the threads are resolved. Test coverage is thorough, including the negative paths and the no-normalization requirement. The changeset accurately describes the shipped behavior. Conformance baseline caveats are documented and depend on sibling PRs, which is a sequencing question for maintainers.

… 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
…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.
@mattzcarey mattzcarey force-pushed the feat/sep-2468-iss-validation branch from 37f812b to 079b624 Compare June 25, 2026 14:08
Comment thread packages/client/src/client/auth.ts
Comment thread .changeset/sep-2468-iss-validation.md Outdated
Comment thread packages/client/src/client/auth.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 packages/client/src/client/auth.ts:736-739 — Discovery state persisted by an older SDK release via the legacy no-PRM fallback (where authorizationServerUrl = MCP origin but the metadata's issuer = the real IdP) now fails the new validateAuthorizationServerMetadataIssuer() check on the cached-state path of authInternal() with a plain Error, which the auth() recovery wrapper never handles — so every auth() call (including silent token refresh) fails permanently until the application manually calls invalidateCredentials('discovery'). Consider invalidating/ignoring the cached discovery state on this mismatch and falling through to full re-discovery (which would now succeed thanks to this PR's own validateIssuer: false + issuer-swap handling for the fallback path), or at minimum throwing a typed, recoverable error.

    Extended reasoning...

    What the bug is

    The cached-discovery-state branch of authInternal() (packages/client/src/client/auth.ts:736-739) now unconditionally calls validateAuthorizationServerMetadataIssuer(metadata, cachedState.authorizationServerUrl). That helper throws a plain Error when the cached authorizationServerUrl and the metadata's issuer differ (modulo trailing slash). The problem is that there is a legitimate, previously-valid configuration that produces exactly this mismatch in already-persisted state: discovery state written by an earlier SDK release via the legacy no-PRM fallback, where authorizationServerUrl was set to the MCP server origin while authorizationServerMetadata.issuer was the real IdP. The old SDK never validated or swapped these, so providers that implement saveDiscoveryState()/discoveryState() (cross-session persistence is the documented purpose of OAuthDiscoveryState) can have this state on disk today.

    Why this PR makes it inconsistent

    The PR itself acknowledges this configuration is legitimate on the fresh-discovery path: commit df9db5b makes discoverOAuthServerInfo() skip issuer validation when the AS URL came from the legacy fallback (validateIssuer: authorizationServerUrlFromResourceMetadata) and swaps authorizationServerUrl to metadata.issuer when they differ, with the test 'uses legacy fallback metadata issuer when it differs from the MCP origin' covering it. So a brand-new client against the same server works fine — but a client upgrading with state persisted by the previous release hits the cached-state check and fails.

    Why there is no recovery path

    The thrown value is a plain Error, not an OAuthError. The recovery wrapper in auth() only catches OAuthError with codes InvalidClient/UnauthorizedClient/InvalidGrant, in which case it calls provider.invalidateCredentials() and retries. For this error nothing is invalidated and there is no fall-through to fresh discovery. Since the cached state is consulted on every authInternal() call — 401-triggered re-auth, silent token refresh, finishAuth() — the failure repeats forever. The error message ('Authorization server metadata issuer does not match the expected issuer ... RFC 8414 Section 3.3') gives no hint that the remedy is for the host application to call invalidateCredentials('discovery').

    Step-by-step proof

    1. Under the previous SDK release, a provider connects to https://mcp.example.com which has no protected-resource metadata. The legacy fallback sets authorizationServerUrl = 'https://mcp.example.com/'; /.well-known/oauth-authorization-server there returns metadata with issuer: 'https://idp.example.com' (a proxied IdP). saveDiscoveryState() persists { authorizationServerUrl: 'https://mcp.example.com/', authorizationServerMetadata: { issuer: 'https://idp.example.com', ... } }. Auth completes; tokens with a refresh token are saved. This was fully supported behavior.
    2. The application upgrades to the SDK containing this PR.
    3. The access token expires; the transport gets a 401 and calls auth(). provider.discoveryState() returns the persisted state, so authInternal() takes the cached branch and calls validateAuthorizationServerMetadataIssuer({ issuer: 'https://idp.example.com', ... }, 'https://mcp.example.com/')'https://idp.example.com' !== 'https://mcp.example.com' → throws Error.
    4. auth()'s catch sees a non-OAuthError and rethrows. No credentials are invalidated, no re-discovery happens. Every subsequent auth() call replays steps 3-4. The connection is permanently broken even though the server configuration is one this PR explicitly continues to support, and even though dropping the cache and re-running discoverOAuthServerInfo() would succeed and persist corrected state (authorizationServerUrl = 'https://idp.example.com').

    Why existing handling doesn't cover it

    The new test 'rejects cached AS metadata with a mismatched issuer' shows the strict check is intentional for adversarial cached metadata, but it doesn't distinguish the stale-but-previously-valid legacy state, and there is no corresponding self-healing path. The earlier review comment on the changeset asked for documentation/gating of the unconditional RFC 8414 check; the fresh-discovery part of that concern was addressed by df9db5b, but this cached-state recovery gap remains.

    How to fix

    In the cached-state branch, when validateAuthorizationServerMetadataIssuer() would fail, treat the cached discovery state as stale: call provider.invalidateCredentials?.('discovery') (or simply ignore the cached state) and fall through to the full discoverOAuthServerInfo() path, which under this PR's own swap logic will resolve the correct issuer and re-persist consistent state. Alternatively (weaker), throw a typed/OAuthError-style error and document the migration step so applications can recover programmatically.

Comment thread packages/client/src/client/auth.ts
Comment thread docs/migration.md Outdated
Comment on lines +1448 to +1453
export async function discoverAuthorizationServerMetadata(
authorizationServerUrl: string | URL,
options: DiscoverAuthorizationServerMetadataOptions = {}
): Promise<AuthorizationServerMetadata | undefined> {
return discoverAuthorizationServerMetadataInternal(authorizationServerUrl, options);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new RFC 8414 issuer validation in discoverAuthorizationServerMetadata() also cascades into the Cross-App Access flow: discoverAndRequestJwtAuthGrant() (packages/client/src/client/crossAppAccess.ts:207) uses this helper to discover the enterprise IdP's token endpoint, so previously-working IdP configurations whose advertised issuer doesn't URL-normalize-match the configured idpUrl (e.g. Azure AD addressed by tenant domain while the metadata issuer uses the tenant GUID, or proxy/host-alias setups) now hard-fail. Neither the changeset/migration notes nor any crossAppAccess test covers this flow's new failure mode — please either opt the IdP-discovery call out of the new validation (it isn't the RFC 9728-derived MCP discovery the check was added for) or explicitly document and test the cascade for discoverAndRequestJwtAuthGrant / CrossAppAccessProvider.

Extended reasoning...

What changes for the Cross-App Access flow

discoverAndRequestJwtAuthGrant() in packages/client/src/client/crossAppAccess.ts:207 calls the public discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn }) to discover the enterprise IdP's token_endpoint for the SEP-990 JWT Authorization Grant flow. This PR rewires that public helper to delegate to discoverAuthorizationServerMetadataInternal() with validateIssuer defaulting to true, so validateAuthorizationServerMetadataIssuer() now runs on every call and throws Authorization server metadata issuer does not match the expected issuer ... (RFC 8414 Section 3.3) whenever the IdP metadata's issuer does not URL-normalize-match the caller-configured idpUrl. Before this PR, this path performed no issuer comparison at all — the metadata was schema-parsed and its token_endpoint used.

Concrete walkthrough

  1. A host configures CrossAppAccessProvider / discoverAndRequestJwtAuthGrant with idpUrl: 'https://login.example-corp.com' (a vanity/alias hostname for their IdP, or an Azure AD tenant addressed by domain name).
  2. https://login.example-corp.com/.well-known/oauth-authorization-server (or the OIDC fallback) returns metadata whose issuer is the canonical identifier — e.g. https://login.microsoftonline.com/<tenant-guid>/v2.0.
  3. On the previous SDK release, the metadata is accepted, the token_endpoint is extracted, and the RFC 8693 token exchange proceeds.
  4. After this PR, discoverAuthorizationServerMetadataInternal() calls validateAuthorizationServerMetadataIssuer(metadata, 'https://login.example-corp.com'), the normalized issuers differ, and the call throws — the entire Cross-App Access flow fails before any token exchange is attempted.

Why nothing in the PR covers this

The changeset and the new docs/migration.md section frame the RFC 8414 check entirely around MCP-server OAuth discovery (PRM-derived AS URLs, cached discovery state, the legacy no-PRM fallback) and the helper itself; discoverAndRequestJwtAuthGrant, CrossAppAccessProvider, and the IdP-discovery use case are never mentioned. The existing crossAppAccess.test.ts fixtures all use issuer: 'https://idp.example.com' equal to the configured idpUrl, so they keep passing and the new throw in this flow is exercised by no test. The migration note's remediation advice ("publish the canonical issuer URL in protected resource metadata") doesn't even apply here, because in this flow the AS URL comes from caller configuration, not PRM.

Addressing the counter-argument

A fair objection is that issuer validation is exactly what RFC 8414 §3.3 / OIDC Discovery require, that the changeset's statement that the public helper now throws transitively covers all consumers, and that the throw itself is unit-tested in auth.test.ts. Those points are why this is a low-severity, non-blocking finding rather than a defect in the validation logic: rejecting mismatched-issuer metadata is defensible on the merits, and exempting this call site is a judgment call rather than an obvious fix. But the cascade is still a real, silent behavior change to a previously-working enterprise flow that the PR's prose, examples, and tests never acknowledge — and it is a different question from MCP-server discovery, because here the comparison anchor is a caller-supplied IdP URL rather than an RFC 9728-derived AS identifier, so the maintainers may well prefer the internal validateIssuer: false opt-out (as already used for the cached-state and legacy-fallback paths) for this call. Either outcome is reasonable; what's missing is the explicit decision plus a test/doc line recording it.

Suggested fix

Pick one of:

  • Call discoverAuthorizationServerMetadataInternal(idpUrl, { fetchFn, validateIssuer: false }) from discoverAndRequestJwtAuthGrant() (or expose the option), keeping strict validation for the MCP discovery paths the check was designed for; or
  • Keep the strict behavior, add a crossAppAccess.test.ts case asserting the mismatched-issuer rejection before token exchange, and add a sentence to the changeset / docs/migration.md noting that Cross-App Access IdP discovery now requires the IdP metadata issuer to match the configured idpUrl.

Comment thread docs/migration.md
Comment on lines +153 to +160
### Authorization server metadata issuer validation

OAuth client discovery now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. When protected resource metadata identifies an authorization server URL, the discovered metadata's `issuer` must match that URL, except for trailing slash normalization.
Cached discovery state is also validated; stale legacy no-PRM fallback state that saved the MCP server origin before learning a distinct metadata issuer is ignored and refreshed. The public `discoverAuthorizationServerMetadata()` helper throws when metadata has a mismatched or
invalid issuer. If your deployment uses host aliases or proxies that serve metadata for a different issuer, publish the canonical issuer URL in protected resource metadata.

For legacy MCP servers without protected resource metadata, the SDK still discovers authorization-server metadata at the MCP server origin. If that origin-hosted metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL saved in
discovery state and used for fallback endpoint construction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new RFC 8414 issuer-validation behavior is documented in docs/migration.md ("Authorization server metadata issuer validation"), but docs/migration-SKILL.md is untouched — root CLAUDE.md requires breaking/behavior changes to be documented in both files. Please add a parallel entry to migration-SKILL.md's "## 13. Behavioral Changes" section (Client subsection) so the two migration docs stay in sync.

Extended reasoning...

What's missing

Root CLAUDE.md ("Breaking Changes" section) requires that breaking/behavior changes be documented in both docs/migration.md (human-readable) and docs/migration-SKILL.md (LLM-optimized mapping tables), and the repo review checklist repeats this requirement. This PR adds a new "Authorization server metadata issuer validation" section to docs/migration.md (lines 153–160) describing the RFC 8414 Section 3.3 rejection behavior and the legacy no-PRM fallback issuer rewrite — i.e. the change was already judged migration-guide-worthy. However, docs/migration-SKILL.md is not touched by this PR and contains no mention of issuer validation, RFC 8414, RFC 9207, or the new discoverAuthorizationServerMetadata() throw (grep for issuer/8414/9207 in that file returns nothing).

Why the SKILL doc is the right place

migration-SKILL.md is not purely an import/code-mapping table: it has a dedicated "## 13. Behavioral Changes" section with a Client subsection that already records exactly this kind of no-code-change, runtime-behavior delta (e.g. list-method capability behavior, resumability gating). The new validation is precisely such a delta: code migrated from v1 to current v2 will now have auth() / discoverAuthorizationServerMetadata() reject AS metadata whose issuer does not match the discovery URL (when the AS URL came from protected resource metadata or cached discovery state), where it previously was accepted. The legacy no-PRM fallback now also adopts the metadata issuer as the persisted authorization server URL.

Concrete walkthrough of the gap

  1. A consumer (or an LLM following the SKILL doc) performs a mechanical v1→v2 migration using docs/migration-SKILL.md as the source of truth, as CLAUDE.md intends.
  2. Their deployment proxies a third-party IdP's metadata under a PRM-listed AS URL, so the discovered metadata's issuer differs from the discovery URL.
  3. After migration, every auth() call (token refresh, finishAuth(code)) throws Authorization server metadata issuer does not match the expected issuer ... (RFC 8414 Section 3.3).
  4. Nothing in migration-SKILL.md mentions this behavior change, so the failure looks like a regression rather than a documented v2 behavioral delta — even though migration.md (which the SKILL consumer may never read) does describe it.

Why nothing else covers it

The earlier review threads asked for the changeset and docs/migration.md to document the behavior change; those were addressed by adding the migration.md section and updating the changeset. None of them mention docs/migration-SKILL.md, so this gap remains. All four verifiers independently confirmed the premises (CLAUDE.md requirement, migration.md section present, SKILL doc lacking any issuer/8414 mention, and the existence of the Behavioral Changes section as a natural home).

How to fix

Add a short paragraph (or table row) to the Client portion of "## 13. Behavioral Changes" in docs/migration-SKILL.md mirroring the new migration.md section: discovered AS metadata whose issuer does not match the PRM-provided/cached authorization server URL is now rejected (RFC 8414 §3.3), discoverAuthorizationServerMetadata() throws on mismatch, and for legacy no-PRM servers the metadata issuer is now used as the saved authorization server URL. This is a couple of lines and keeps the two migration documents in sync per the repo's own convention. Purely a documentation-consistency item with no runtime impact, so it should not block the PR.

Comment on lines +1540 to +1549
if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) {
const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL');
const metadataIssuer = normalizeDiscoveredIssuerIdentifier(
authorizationServerMetadata.issuer,
'Authorization server metadata issuer'
);
if (metadataIssuer !== fallbackIssuer) {
authorizationServerUrl = authorizationServerMetadata.issuer;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 In the legacy no-PRM fallback path of discoverOAuthServerInfo(), the issuer-rewrite block calls normalizeDiscoveredIssuerIdentifier(authorizationServerMetadata.issuer, ...) unguarded, so a legacy server whose origin-hosted metadata has an unparseable issuer (empty string, schemeless host like auth.example.com) now makes every auth() call throw — even though this path deliberately passes validateIssuer: false to keep such deployments working. Wrap the rewrite comparison in try/catch and keep the MCP-origin fallback URL on parse failure, mirroring how isStaleLegacyFallbackDiscoveryState() already swallows parse errors.

Extended reasoning...

What the bug is

The legacy no-PRM fallback in discoverOAuthServerInfo() (packages/client/src/client/auth.ts:1540-1549) was deliberately carved out of the new RFC 8414 issuer validation: when protected-resource metadata is unavailable, the AS metadata is fetched with validateIssuer: false so legacy deployments keep working (commit df9db5b, "preserve legacy OAuth metadata fallback"). But the issuer-rewrite block that follows still calls normalizeDiscoveredIssuerIdentifier(authorizationServerMetadata.issuer, ...) with no guard. That helper does new URL(issuer) and throws \"... is not a valid issuer identifier ... (RFC 8414 Section 3.3)\" for any value the URL constructor can't parse — an empty string, a schemeless host like auth.example.com, or other garbage. OAuthMetadataSchema declares issuer as plain z.string(), so such metadata sails through Zod and reaches the throw.

The code path that triggers it

  1. A legacy MCP server at https://mcp.example.com serves no protected-resource metadata, so PRM discovery fails and authorizationServerUrl falls back to https://mcp.example.com/ with authorizationServerUrlFromResourceMetadata = false.
  2. /.well-known/oauth-authorization-server at the origin returns metadata with explicit authorization_endpoint/token_endpoint fields but issuer: \"auth.example.com\" (no scheme) or issuer: \"\".
  3. discoverAuthorizationServerMetadataInternal(..., { validateIssuer: false }) accepts it — exactly as the carve-out intends.
  4. The rewrite block then calls normalizeDiscoveredIssuerIdentifier(authorizationServerMetadata.issuer, 'Authorization server metadata issuer'); new URL('auth.example.com') throws, and the descriptive RFC 8414 error propagates out of discoverOAuthServerInfo().
  5. Because the throw is a plain Error (not OAuthError), auth()'s recovery wrapper does not retry or invalidate anything — every auth() call against this server (initial connect, finishAuth(code), token refresh after cache invalidation) fails the same way.

Why existing code doesn't prevent it

Before this PR, metadata.issuer was never parsed anywhere in the client; the legacy flow used the explicit *_endpoint fields and worked end-to-end against such servers. The new validateIssuer: false flag correctly skips the comparison check, but the issuer-rewrite (also new in this PR) re-parses the same value one line later. The internal inconsistency is visible in the sibling helper: isStaleLegacyFallbackDiscoveryState() performs the equivalent comparison for cached state and wraps the same normalization calls in try/catch, returning false on parse failure — the fresh-discovery path simply doesn't.

Impact

A previously-working legacy deployment whose metadata issuer happens to be unparseable hard-fails on upgrade, on the very path the changeset and docs/migration.md promise keeps working ("For legacy MCP servers without protected resource metadata, metadata is still discovered at the MCP server origin..."). The carve-out tolerates an issuer pointing at a completely different host (and even adopts it as the AS URL), but breaks on one that is merely not URL-parseable. Severity is limited because the trigger requires a non-RFC-8414-conformant issuer value (the spec mandates an https URL), so this should not block the PR — but it is a real backwards-compat regression in exactly the deployments the carve-out was written to protect.

How to fix

Wrap the rewrite comparison in try/catch and keep the MCP-origin fallback URL when either side fails to parse, mirroring isStaleLegacyFallbackDiscoveryState():

if (!authorizationServerUrlFromResourceMetadata && authorizationServerMetadata) {
    try {
        const fallbackIssuer = normalizeDiscoveredIssuerIdentifier(authorizationServerUrl, 'Authorization server URL');
        const metadataIssuer = normalizeDiscoveredIssuerIdentifier(
            authorizationServerMetadata.issuer,
            'Authorization server metadata issuer'
        );
        if (metadataIssuer !== fallbackIssuer) {
            authorizationServerUrl = authorizationServerMetadata.issuer;
        }
    } catch {
        // Unparseable issuer in legacy fallback metadata — keep the MCP-origin URL,
        // matching isStaleLegacyFallbackDiscoveryState().
    }
}

This keeps the new issuer-adoption behavior for parseable issuers while preserving pre-PR behavior for the sloppy-issuer legacy case.

Comment on lines +1429 to +1453
/**
* Discovers authorization server metadata with support for
* {@link https://datatracker.ietf.org/doc/html/rfc8414 | RFC 8414} OAuth 2.0
* Authorization Server Metadata and
* {@link https://openid.net/specs/openid-connect-discovery-1_0.html | OpenID Connect Discovery 1.0}
* specifications.
*
* This function implements a fallback strategy for authorization server discovery:
* 1. Attempts RFC 8414 OAuth metadata discovery first
* 2. If OAuth discovery fails, falls back to OpenID Connect Discovery
*
* @param authorizationServerUrl - The authorization server URL obtained from the MCP Server's
* protected resource metadata, or the MCP server's URL if the
* metadata was not found.
* @param options - Configuration options
* @param options.fetchFn - Optional fetch function for making HTTP requests, defaults to global fetch
* @param options.protocolVersion - MCP protocol version to use, defaults to {@linkcode LATEST_PROTOCOL_VERSION}
* @returns Promise resolving to authorization server metadata, or undefined if discovery fails
*/
export async function discoverAuthorizationServerMetadata(
authorizationServerUrl: string | URL,
options: DiscoverAuthorizationServerMetadataOptions = {}
): Promise<AuthorizationServerMetadata | undefined> {
return discoverAuthorizationServerMetadataInternal(authorizationServerUrl, options);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The JSDoc on the public discoverAuthorizationServerMetadata() wrapper still says it returns "authorization server metadata, or undefined if discovery fails", but the function now delegates to the internal helper with validateIssuer defaulting to true and throws on issuer mismatch or unparseable issuers (RFC 8414 Section 3.3). Add a sentence and/or @throws tag describing the new issuer validation so the IDE/typedoc-facing API docs match the behavior the changeset and migration guide already document.

Extended reasoning...

What the bug is

This PR splits discoverAuthorizationServerMetadata() into an internal implementation (discoverAuthorizationServerMetadataInternal(), which accepts validateIssuer) and a thin public wrapper at packages/client/src/client/auth.ts:1448-1453. The wrapper calls the internal function with no validateIssuer override, so the option defaults to true and validateAuthorizationServerMetadataIssuer() runs on every successful metadata fetch. The JSDoc block placed on the wrapper (lines 1429-1447) was carried over verbatim from the old function: it describes only the OAuth/OIDC fallback discovery strategy and ends with @returns Promise resolving to authorization server metadata, or undefined if discovery fails. Nothing mentions the new RFC 8414 Section 3.3 issuer validation or the fact that the function now rejects.

The code path that triggers the discrepancy

When a consumer calls discoverAuthorizationServerMetadata('https://auth.example.com') and the fetched metadata's issuer does not URL-normalize-match that URL, discoverAuthorizationServerMetadataInternal() throws Authorization server metadata issuer does not match the expected issuer: expected ..., got ... (RFC 8414 Section 3.3); an unparseable issuer (e.g. a schemeless host) throws ... is not a valid issuer identifier .... The PR's own tests assert exactly this: "rejects OAuth metadata whose issuer does not match the authorization server URL", "rejects OAuth metadata whose issuer is not a valid URL with a descriptive error", and "rejects OpenID metadata whose issuer does not match the authorization server URL" in packages/client/test/client/auth.test.ts. So the throw is the intended, tested contract — only the API doc fails to describe it.

Step-by-step proof of the mismatch

  1. A consumer reads the JSDoc in their IDE: "returns ... metadata, or undefined if discovery fails". They reasonably write const md = await discoverAuthorizationServerMetadata(asUrl); if (!md) { /* handle missing discovery */ }.
  2. After upgrading, they point this at a server whose metadata issuer differs from asUrl (e.g. a proxy/host-alias setup, or the Cross-App Access idpUrl path via discoverAndRequestJwtAuthGrant()).
  3. Instead of getting undefined, the call throws an Error they had no documented reason to anticipate, and their if (!md) handling never runs.
  4. The changeset and the new docs/migration.md section do describe the throw — but the JSDoc that surfaces in IDE hover/typedoc, the documentation closest to the call site, still describes the pre-PR contract.

Why nothing else in the diff covers this

The earlier review threads on this PR asked for the changeset, docs/migration.md, and docs/migration-SKILL.md to be updated; those were addressed (or are tracked separately). None of them touch the function-level JSDoc, which is newly authored in this same diff and therefore should accurately reflect the function it documents. To be fair, the old JSDoc was never an exhaustive throw contract — the function already threw on non-4xx/502 HTTP errors and Zod parse failures — so this is an omission rather than an outright contradiction, which is why it's a nit rather than a blocker.

How to fix

Add one sentence to the description (e.g. "Discovered metadata is validated per RFC 8414 Section 3.3: if its issuer does not match authorizationServerUrl (or is not a valid issuer identifier), the promise rejects.") and/or a @throws tag, e.g. @throws Error when the discovered metadata's issuer does not match the authorization server URL or is not a valid issuer identifier (RFC 8414 Section 3.3). One- or two-line change; should not block the PR.

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