fix: rename routePrefix to issuerPath and add issuer-mismatch scenario by pcarleton · Pull Request #203 · modelcontextprotocol/conformance · GitHub
Skip to content

fix: rename routePrefix to issuerPath and add issuer-mismatch scenario#203

Open
pcarleton wants to merge 1 commit intomainfrom
paulc/fix-issuer-path-derivation
Open

fix: rename routePrefix to issuerPath and add issuer-mismatch scenario#203
pcarleton wants to merge 1 commit intomainfrom
paulc/fix-issuer-path-derivation

Conversation

@pcarleton
Copy link
Copy Markdown
Member

@pcarleton pcarleton commented Mar 30, 2026

The previous fix (#152) used routePrefix for the issuer path, conflating two concepts: (1) where OAuth endpoints are mounted and (2) the issuer path component per RFC 8414. This worked for metadata-var2/var3 where both were /tenant1, but broke auth/2025-03-26-oauth-metadata-backcompat where routePrefix was /oauth (endpoint namespacing) but the issuer should be root.

Changes:

  • Rename routePrefix -> issuerPath in createAuthServer. Endpoints are mounted under the issuer path (the real-world multi-tenant model).
  • Drop the /oauth prefix from march-spec-backcompat entirely. No route collision exists with createServer, and test integrity against fallback-bypass is already guaranteed by expectedSlugs requiring authorization-server-metadata.
  • Rename authRoutePrefix -> authIssuerPath in discovery-metadata config.
  • Add issuerOverride option to createAuthServer for negative testing.
  • Add auth/issuer-mismatch scenario: AS returns a mismatched issuer (https://evil.example.com) and the client is expected to reject per RFC 8414 section 3.3. Skipped in CI for now as the TS SDK reference client does not yet validate issuer.

Fixes #140

The previous fix (#152) used routePrefix for the issuer path, conflating
two concepts: (1) where OAuth endpoints are mounted and (2) the issuer
path component per RFC 8414. This worked for metadata-var2/var3 where
both were /tenant1, but broke auth/2025-03-26-oauth-metadata-backcompat
where routePrefix was /oauth (endpoint namespacing) but the issuer
should be root.

Changes:
- Rename routePrefix -> issuerPath in createAuthServer. Endpoints are
  mounted under the issuer path (the real-world multi-tenant model).
- Drop the /oauth prefix from march-spec-backcompat entirely. No route
  collision exists with createServer, and test integrity against
  fallback-bypass is already guaranteed by expectedSlugs requiring
  authorization-server-metadata.
- Rename authRoutePrefix -> authIssuerPath in discovery-metadata config.
- Add issuerOverride option to createAuthServer for negative testing.
- Add auth/issuer-mismatch scenario: AS returns a mismatched issuer
  (https://evil.example.com) and the client is expected to reject per
  RFC 8414 section 3.3. Skipped in CI for now as the TS SDK reference
  client does not yet validate issuer.

Fixes #140
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Mar 30, 2026

@pcarleton
Copy link
Copy Markdown
Member Author

@claude review

1 similar comment
@pcarleton
Copy link
Copy Markdown
Member Author

@claude review

Comment on lines +79 to +96
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 This check can false-pass: correctlyRejected is derived solely from \!authorizationRequestMade, so a client that errors out before ever fetching AS metadata (e.g. fails PRM parsing or doesn't support OAuth at all) will be reported as having 'correctly rejected the mismatched issuer' — even though it never saw the issuer. Consider gating SUCCESS on this.checks.some(c => c.id === 'authorization-server-metadata') so the client must have actually fetched the metadata containing the bad issuer. (Note: resource-mismatch.ts has the analogous pre-existing weakness for PRM, but this PR adds a fresh instance.)

Extended reasoning...

What the bug is

IssuerMismatchScenario.getChecks() decides pass/fail purely on whether the /authorize endpoint was hit:

const correctlyRejected = \!this.authorizationRequestMade;
// ...
status: correctlyRejected ? 'SUCCESS' : 'FAILURE',

It never checks whether the client actually fetched the AS metadata document containing the mismatched issuer. Combined with allowClientError = true (line 35), any client that errors out early — for reasons completely unrelated to issuer validation — will be credited with a SUCCESS.

Code path that triggers it

  1. Client hits /mcp, receives 401 with WWW-Authenticate pointing at the PRM.
  2. Client fails at this stage — e.g. it doesn't implement OAuth, can't parse the PRM response, or hits any unrelated exception.
  3. Client process exits non-zero. Because allowClientError = true, the runner tolerates this.
  4. /authorize was never called → authorizationRequestMade is still false.
  5. /.well-known/oauth-authorization-server was also never called → no authorization-server-metadata check was pushed into this.checks.
  6. getChecks() computes correctlyRejected = \!false = true and pushes a SUCCESS for issuer-mismatch-rejected with the description "Client correctly rejected authorization when AS metadata issuer does not match the metadata URL".

The client never saw issuer: "https://evil.example.com", yet the conformance report says it correctly validated it.

Why existing code doesn't prevent it

Unlike discovery-metadata.ts and march-spec-backcompat.ts, this scenario has no expectedSlugs precondition requiring authorization-server-metadata to be present. The only signal used is the absence of an authorize call, which is necessary but not sufficient — it conflates "rejected after seeing the mismatch" with "never got that far".

createAuthServer already pushes an authorization-server-metadata check into this.checks when the metadata endpoint is hit (createAuthServer.ts:132-146), so the signal needed to disambiguate is already available — it's just not consulted.

Impact

For a conformance suite, false positives are arguably worse than false negatives: a client that doesn't implement RFC 8414 §3.3 issuer validation (or doesn't implement OAuth discovery at all) could be reported as conformant on this check. This is a draft/informational scenario and currently skipped in CI for the TS reference client, so the immediate blast radius is small — but third-party clients running the suite would get misleading results.

Fix

Gate the SUCCESS on the metadata having been fetched:

const sawMetadata = this.checks.some(
  (c) => c.id === 'authorization-server-metadata'
);
const correctlyRejected = sawMetadata && \!this.authorizationRequestMade;
// ...
description: \!sawMetadata
  ? 'Client never fetched AS metadata, so issuer validation could not be tested'
  : correctlyRejected
    ? 'Client correctly rejected authorization when AS metadata issuer does not match the metadata URL'
    : 'Client MUST validate that the issuer in AS metadata matches the issuer used to construct the metadata URL (RFC 8414 §3.3)',

resource-mismatch.ts has the same structural weakness (it should gate on the PRM endpoint having been hit), but that's pre-existing and out of scope for this 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.

AS issuer validation failure

1 participant