diff --git a/.changeset/sep-2468-iss-validation.md b/.changeset/sep-2468-iss-validation.md new file mode 100644 index 000000000..7c0e587ec --- /dev/null +++ b/.changeset/sep-2468-iss-validation.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': minor +--- + +Add RFC 9207 `iss` parameter validation for authorization responses (SEP-2468). `OAuthMetadataSchema` and `OpenIdProviderMetadataSchema` now recognize `authorization_response_iss_parameter_supported`. The client exports a new `validateAuthorizationResponseIssuer()` helper, +`auth()` accepts an optional `iss`, and `StreamableHTTPClientTransport.finishAuth()` / `SSEClientTransport.finishAuth()` accept an optional `{ iss }` second argument. The `iss` option is tri-state: a string is validated by exact comparison against the issuer recorded in the +authorization server metadata before the authorization code is sent to any token endpoint (mismatch rejects the response without processing any other response parameters); `null` asserts the caller inspected the authorization response and it carried no `iss`, enabling the RFC +9207 fail-closed rejection when the AS advertises `authorization_response_iss_parameter_supported: true`; `undefined` (omitted) skips RFC 9207 response validation, so existing `finishAuth(code)` callers that never see the authorization response are unaffected. + +Discovery also now validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a PRM-provided authorization server URL is rejected when its `issuer` does not match that URL, and the public `discoverAuthorizationServerMetadata()` helper +throws on mismatches or invalid issuer identifiers unless called with `{ validateIssuer: false }` for intentional alias discovery. 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. For legacy servers without protected resource metadata, metadata is still discovered at the MCP server origin; when that metadata names a distinct issuer, the SDK now treats the metadata `issuer` as the authorization server URL for persisted +discovery state and fallback endpoint construction. diff --git a/docs/client.md b/docs/client.md index c2bb5b05b..dca9b9527 100644 --- a/docs/client.md +++ b/docs/client.md @@ -164,7 +164,7 @@ For a runnable example supporting both auth methods via environment variables, s ### Full OAuth with user authorization -For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(code)}, and reconnect. +For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, extract the callback `code` and `iss` query parameters, call {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth} with the code and `iss` option, and reconnect. Pass `iss: null` when the callback was inspected and omitted `iss`; leaving it `undefined` preserves legacy behavior and skips RFC 9207 issuer validation. For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClient.ts) and [`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleOAuthClientProvider.ts). diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 73a2e4a0b..7eecf9ff6 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -515,6 +515,10 @@ members of the request/result/notification unions, the `tasks` capability key, ` `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. +OAuth client discovery validates authorization-server metadata issuer values per RFC 8414 Section 3.3. Metadata discovered for a protected-resource metadata authorization server URL must have a matching `issuer`; cached discovery state is also revalidated. The public +`discoverAuthorizationServerMetadata()` helper throws for mismatched or invalid issuers unless called with `{ validateIssuer: false }`. Legacy no-PRM fallback still discovers metadata at the MCP server origin and adopts a distinct valid metadata `issuer` for saved discovery +state. + ### Server (Streamable HTTP transport) No code changes required; these are wire-behavior notes: diff --git a/docs/migration.md b/docs/migration.md index 1b6062225..56fbbeb2a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -157,6 +157,15 @@ a working demo with `better-auth`. Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. +### 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 after standard URL parsing/serialization and +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 unless called with `{ validateIssuer: false }`. 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. + ### `Headers` object instead of plain objects Transport APIs and `RequestInfo.headers` now use the Web Standard `Headers` object instead of plain `Record` (`IsomorphicHeaders` has been removed). diff --git a/examples/client/src/elicitationUrlExample.ts b/examples/client/src/elicitationUrlExample.ts index f1b6db3b5..519c68506 100644 --- a/examples/client/src/elicitationUrlExample.ts +++ b/examples/client/src/elicitationUrlExample.ts @@ -440,8 +440,8 @@ async function handleURLElicitation(params: ElicitRequestURLParams): Promise { - return new Promise((resolve, reject) => { +async function waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> { + return new Promise<{ code: string; iss: string | null }>((resolve, reject) => { const server = createServer((req, res) => { // Ignore favicon requests if (req.url === '/favicon.ico') { @@ -453,6 +453,7 @@ async function waitForOAuthCallback(): Promise { console.log(`📥 Received callback: ${req.url}`); const parsedUrl = new URL(req.url || '', 'http://localhost'); const code = parsedUrl.searchParams.get('code'); + const iss = parsedUrl.searchParams.get('iss'); const error = parsedUrl.searchParams.get('error'); if (code) { @@ -469,7 +470,7 @@ async function waitForOAuthCallback(): Promise { `); - resolve(code); + resolve({ code, iss }); setTimeout(() => server.close(), 15_000); } else if (error) { console.log(`❌ Authorization error: ${error}`); @@ -519,9 +520,8 @@ async function attemptConnection(oauthProvider: InMemoryOAuthClientProvider): Pr } catch (error) { if (error instanceof UnauthorizedError) { console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); + const { code: authCode, iss } = await waitForOAuthCallback(); + await transport.finishAuth(authCode, { iss }); console.log('🔐 Authorization code received:', authCode); console.log('🔌 Reconnecting with authenticated transport...'); // Recursively retry connection after OAuth completion diff --git a/examples/client/src/simpleOAuthClient.ts b/examples/client/src/simpleOAuthClient.ts index a0a24b145..19ad1734a 100644 --- a/examples/client/src/simpleOAuthClient.ts +++ b/examples/client/src/simpleOAuthClient.ts @@ -72,8 +72,8 @@ class InteractiveOAuthClient { /** * Starts a temporary HTTP server to receive the OAuth callback */ - private async waitForOAuthCallback(): Promise { - return new Promise((resolve, reject) => { + private async waitForOAuthCallback(): Promise<{ code: string; iss: string | null }> { + return new Promise<{ code: string; iss: string | null }>((resolve, reject) => { const server = createServer((req, res) => { // Ignore favicon requests if (req.url === '/favicon.ico') { @@ -85,6 +85,7 @@ class InteractiveOAuthClient { console.log(`📥 Received callback: ${req.url}`); const parsedUrl = new URL(req.url || '', 'http://localhost'); const code = parsedUrl.searchParams.get('code'); + const iss = parsedUrl.searchParams.get('iss'); const error = parsedUrl.searchParams.get('error'); if (code) { @@ -100,7 +101,7 @@ class InteractiveOAuthClient { `); - resolve(code); + resolve({ code, iss }); setTimeout(() => server.close(), 3000); } else if (error) { console.log(`❌ Authorization error: ${error}`); @@ -143,9 +144,8 @@ class InteractiveOAuthClient { } catch (error) { if (error instanceof UnauthorizedError) { console.log('🔐 OAuth required - waiting for authorization...'); - const callbackPromise = this.waitForOAuthCallback(); - const authCode = await callbackPromise; - await transport.finishAuth(authCode); + const { code: authCode, iss } = await this.waitForOAuthCallback(); + await transport.finishAuth(authCode, { iss }); console.log('🔐 Authorization code received:', authCode); console.log('🔌 Reconnecting with authenticated transport...'); await this.attemptConnection(oauthProvider); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9e47a3820..dd99d6caa 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -531,6 +531,116 @@ export async function parseErrorResponse(input: Response | string): Promise { const headers = { 'MCP-Protocol-Version': protocolVersion, @@ -1263,14 +1431,50 @@ export async function discoverAuthorizationServerMetadata( } // Parse and validate based on type - return type === 'oauth' - ? OAuthMetadataSchema.parse(await response.json()) - : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + const metadata = + type === 'oauth' + ? OAuthMetadataSchema.parse(await response.json()) + : OpenIdProviderDiscoveryMetadataSchema.parse(await response.json()); + + if (validateIssuer) { + validateAuthorizationServerMetadataIssuer(metadata, authorizationServerUrl); + } + + return metadata; } return undefined; } +/** + * 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} + * @param options.validateIssuer - Whether to validate metadata's issuer against the discovery URL, defaults to true + * @returns Promise resolving to authorization server metadata, or undefined if discovery fails + * @throws {Error} If discovered metadata has an invalid issuer or the issuer does not match + * the discovery URL while issuer validation is enabled + */ +export async function discoverAuthorizationServerMetadata( + authorizationServerUrl: string | URL, + options: DiscoverAuthorizationServerMetadataOptions = {} +): Promise { + return discoverAuthorizationServerMetadataInternal(authorizationServerUrl, options); +} + /** * Result of {@linkcode discoverOAuthServerInfo}. */ @@ -1323,6 +1527,7 @@ export async function discoverOAuthServerInfo( ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | undefined; + let authorizationServerUrlFromResourceMetadata = false; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata( @@ -1332,6 +1537,7 @@ export async function discoverOAuthServerInfo( ); if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; + authorizationServerUrlFromResourceMetadata = true; } } catch (error) { // Network failures (DNS, connection refused) surface as TypeError from fetch. Those are @@ -1349,7 +1555,26 @@ export async function discoverOAuthServerInfo( authorizationServerUrl = String(new URL('/', serverUrl)); } - const authorizationServerMetadata = await discoverAuthorizationServerMetadata(authorizationServerUrl, { fetchFn: opts?.fetchFn }); + const authorizationServerMetadata = await discoverAuthorizationServerMetadataInternal(authorizationServerUrl, { + fetchFn: opts?.fetchFn, + validateIssuer: authorizationServerUrlFromResourceMetadata + }); + + 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 { + // Legacy no-PRM discovery intentionally disables issuer validation. Keep the + // fallback MCP origin when legacy metadata has an unparseable issuer value. + } + } return { authorizationServerUrl, diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 2783f2002..52e0d3bbe 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -203,8 +203,8 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO export async function discoverAndRequestJwtAuthGrant(options: DiscoverAndRequestJwtAuthGrantOptions): Promise { const { idpUrl, fetchFn = fetch, ...restOptions } = options; - // Discover IdP's authorization server metadata - const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn }); + // Enterprise IdP URLs are caller-configured and may be aliases for a canonical issuer. + const metadata = await discoverAuthorizationServerMetadata(String(idpUrl), { fetchFn, validateIssuer: false }); if (!metadata?.token_endpoint) { throw new Error(`Failed to discover token endpoint for IdP: ${idpUrl}`); diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 3038ebfd8..58b159f36 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -228,8 +228,17 @@ export class SSEClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The authorization code from the authorization response + * @param options.iss - The `iss` parameter from the authorization response. Pass the string + * value when present; pass `null` to assert the authorization response was inspected and + * contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises + * `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response + * parameters were not available — validation is then skipped. When provided, the value is + * validated against the issuer recorded in the authorization server metadata per RFC 9207 + * before the code is exchanged. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } @@ -237,6 +246,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: options?.iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 8962cf563..8f8406ae1 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -489,8 +489,17 @@ export class StreamableHTTPClientTransport implements Transport { /** * Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth. + * + * @param authorizationCode - The authorization code from the authorization response + * @param options.iss - The `iss` parameter from the authorization response. Pass the string + * value when present; pass `null` to assert the authorization response was inspected and + * contained no `iss` (this enables the RFC 9207 fail-closed rejection when the AS advertises + * `authorization_response_iss_parameter_supported: true`). Leave `undefined` when the response + * parameters were not available — validation is then skipped. When provided, the value is + * validated against the issuer recorded in the authorization server metadata per RFC 9207 + * before the code is exchanged. */ - async finishAuth(authorizationCode: string): Promise { + async finishAuth(authorizationCode: string, options?: { iss?: string | null }): Promise { if (!this._oauthProvider) { throw new UnauthorizedError('finishAuth requires an OAuthClientProvider'); } @@ -498,6 +507,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._oauthProvider, { serverUrl: this._url, authorizationCode, + iss: options?.iss, resourceMetadataUrl: this._resourceMetadataUrl, scope: this._scope, fetchFn: this._fetchWithInit diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b48d7cd0e..3f433de36 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -11,6 +11,7 @@ export type { AuthProvider, AuthResult, ClientAuthMethod, + DiscoverAuthorizationServerMetadataOptions, OAuthClientProvider, OAuthDiscoveryState, OAuthServerInfo @@ -35,6 +36,7 @@ export { selectResourceURL, startAuthorization, UnauthorizedError, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from './client/auth'; export type { diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index a4b22e0c4..185e5f495 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -19,6 +19,7 @@ import { registerClient, selectClientAuthMethod, startAuthorization, + validateAuthorizationResponseIssuer, validateClientMetadataUrl } from '../../src/client/auth'; import { createPrivateKeyJwtAuth } from '../../src/client/authExtensions'; @@ -900,6 +901,14 @@ describe('OAuth Authorization', () => { code_challenge_methods_supported: ['S256'] }; + const validOpenIdTenantMetadata = { + ...validOpenIdMetadata, + issuer: 'https://auth.example.com/tenant1', + authorization_endpoint: 'https://auth.example.com/tenant1/authorize', + token_endpoint: 'https://auth.example.com/tenant1/token', + jwks_uri: 'https://auth.example.com/tenant1/jwks' + }; + it('tries URLs in order and returns first successful metadata', async () => { // First OAuth URL (path before well-known) fails with 404 mockFetch.mockResolvedValueOnce({ @@ -911,12 +920,12 @@ describe('OAuth Authorization', () => { mockFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: async () => validOpenIdMetadata + json: async () => validOpenIdTenantMetadata }); const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com/tenant1'); - expect(metadata).toEqual(validOpenIdMetadata); + expect(metadata).toEqual(validOpenIdTenantMetadata); // Verify it tried the URLs in the correct order const calls = mockFetch.mock.calls; @@ -937,11 +946,77 @@ describe('OAuth Authorization', () => { json: async () => validOpenIdMetadata }); - const metadata = await discoverAuthorizationServerMetadata('https://mcp.example.com'); + const metadata = await discoverAuthorizationServerMetadata('https://auth.example.com'); expect(metadata).toEqual(validOpenIdMetadata); }); + it('rejects OAuth metadata whose issuer does not match the authorization server URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'https://attacker.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer does not match the expected issuer/ + ); + }); + + it('rejects OAuth metadata whose issuer is not a valid URL with a descriptive error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'auth.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer is not a valid issuer identifier: got auth\.example\.com/ + ); + }); + + it('can opt out of metadata issuer validation for alias discovery', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOAuthMetadata, + issuer: 'https://canonical-idp.example.com' + }) + }); + + const metadata = await discoverAuthorizationServerMetadata('https://idp-alias.example.com', { + validateIssuer: false + }); + + expect(metadata?.issuer).toBe('https://canonical-idp.example.com'); + }); + + it('rejects OpenID metadata whose issuer does not match the authorization server URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + }); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + ...validOpenIdMetadata, + issuer: 'https://attacker.example.com' + }) + }); + + await expect(discoverAuthorizationServerMetadata('https://auth.example.com')).rejects.toThrow( + /Authorization server metadata issuer does not match the expected issuer/ + ); + }); + it('continues on 502 and tries next URL', async () => { // First URL (OAuth) returns 502 (reverse proxy with no route) mockFetch.mockResolvedValueOnce({ @@ -1147,6 +1222,79 @@ describe('OAuth Authorization', () => { expect(result.authorizationServerMetadata).toBeDefined(); }); + it('uses legacy fallback metadata issuer when it differs from the MCP origin', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'https://auth.example.com/oauth', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token' + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://auth.example.com/oauth'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toEqual(legacyAuthMetadata); + expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); + }); + + it('keeps the legacy fallback MCP origin when unvalidated metadata has an invalid issuer', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'auth.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token' + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await discoverOAuthServerInfo('https://resource.example.com'); + + expect(result.authorizationServerUrl).toBe('https://resource.example.com/'); + expect(result.resourceMetadata).toBeUndefined(); + expect(result.authorizationServerMetadata).toEqual(legacyAuthMetadata); + }); + it('forwards resourceMetadataUrl override to protected resource metadata discovery', async () => { const overrideUrl = new URL('https://custom.example.com/.well-known/oauth-protected-resource'); @@ -1384,6 +1532,80 @@ describe('OAuth Authorization', () => { ); }); + it('rediscovers stale legacy fallback state whose cached URL differs from the metadata issuer', async () => { + const legacyAuthMetadata = { + ...validAuthMetadata, + issuer: 'https://idp.example.com/oauth', + authorization_endpoint: 'https://idp.example.com/oauth/authorize', + token_endpoint: 'https://idp.example.com/oauth/token' + }; + const invalidateCredentials = vi.fn(); + const saveDiscoveryState = vi.fn(); + const provider = createMockProvider({ + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: 'https://resource.example.com/', + authorizationServerMetadata: legacyAuthMetadata + }), + invalidateCredentials, + saveDiscoveryState, + tokens: vi.fn().mockResolvedValue({ + access_token: 'valid-token', + refresh_token: 'refresh-token', + token_type: 'bearer' + }) + }); + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404, + text: async () => '' + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => legacyAuthMetadata + }); + } + + if (urlString === legacyAuthMetadata.token_endpoint) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-token', + token_type: 'bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + + const result = await auth(provider, { serverUrl: 'https://resource.example.com' }); + + expect(result).toBe('AUTHORIZED'); + expect(invalidateCredentials).toHaveBeenCalledWith('discovery'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: legacyAuthMetadata.issuer, + resourceMetadata: undefined, + authorizationServerMetadata: legacyAuthMetadata + }) + ); + + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString() === legacyAuthMetadata.token_endpoint); + expect(tokenCall).toBeDefined(); + }); + it('uses resourceMetadataUrl from cached discovery state for PRM discovery', async () => { const cachedPrmUrl = 'https://custom.example.com/.well-known/oauth-protected-resource'; const provider = createMockProvider({ @@ -2295,10 +2517,10 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + registration_endpoint: 'https://resource.example.com/register', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2345,6 +2567,162 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls[1]![0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); }); + it('saves the metadata issuer as the AS URL for legacy no-PRM fallback', async () => { + const saveDiscoveryState = vi.fn(); + const saveAuthorizationServerUrl = vi.fn(); + const provider: OAuthClientProvider = { + ...mockProvider, + clientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn(), + saveCodeVerifier: vi.fn(), + redirectToAuthorization: vi.fn(), + saveDiscoveryState, + saveAuthorizationServerUrl + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com/oauth', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + registration_endpoint: 'https://auth.example.com/oauth/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString === 'https://auth.example.com/oauth/register') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://auth.example.com/oauth', + resourceMetadata: undefined, + authorizationServerMetadata: expect.objectContaining({ + issuer: 'https://auth.example.com/oauth' + }) + }) + ); + expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://auth.example.com/oauth'); + + const redirectCall = (provider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize'); + }); + + it('keeps the fallback MCP origin when legacy no-PRM auth metadata has an invalid issuer', async () => { + const saveDiscoveryState = vi.fn(); + const saveAuthorizationServerUrl = vi.fn(); + const provider: OAuthClientProvider = { + ...mockProvider, + clientInformation: vi.fn().mockResolvedValue(undefined), + tokens: vi.fn().mockResolvedValue(undefined), + saveClientInformation: vi.fn(), + saveCodeVerifier: vi.fn(), + redirectToAuthorization: vi.fn(), + saveDiscoveryState, + saveAuthorizationServerUrl + }; + + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: false, + status: 404 + }); + } + + if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'auth.example.com', + authorization_endpoint: 'https://auth.example.com/oauth/authorize', + token_endpoint: 'https://auth.example.com/oauth/token', + registration_endpoint: 'https://auth.example.com/oauth/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString === 'https://auth.example.com/oauth/register') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'test-client-id', + client_secret: 'test-client-secret', + client_id_issued_at: 1_612_137_600, + client_secret_expires_at: 1_612_224_000, + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + expect(saveDiscoveryState).toHaveBeenCalledWith( + expect.objectContaining({ + authorizationServerUrl: 'https://resource.example.com/', + resourceMetadata: undefined, + authorizationServerMetadata: expect.objectContaining({ + issuer: 'auth.example.com' + }) + }) + ); + expect(saveAuthorizationServerUrl).toHaveBeenCalledWith('https://resource.example.com/'); + + const redirectCall = (provider.redirectToAuthorization as Mock).mock.calls[0]!; + const authUrl: URL = redirectCall[0]; + expect(authUrl.origin + authUrl.pathname).toBe('https://auth.example.com/oauth/authorize'); + }); + it('uses base URL (with root path) as authorization server when protected-resource-metadata discovery fails', async () => { // Setup: First call to protected resource metadata fails (404) // When no authorization_servers are found in protected resource metadata, @@ -2748,9 +3126,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2804,9 +3182,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -2868,9 +3246,9 @@ describe('OAuth Authorization', () => { ok: true, status: 200, json: async () => ({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', + issuer: 'https://api.example.com', + authorization_endpoint: 'https://api.example.com/authorize', + token_endpoint: 'https://api.example.com/token', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -4149,3 +4527,267 @@ describe('OAuth Authorization', () => { }); }); }); + +describe('SEP-2468: RFC 9207 authorization response iss validation', () => { + const issuer = 'https://auth.example.com'; + + describe('validateAuthorizationResponseIssuer', () => { + // RFC 9207 Section 2.4, row 1: advertised but absent -> reject. The caller signals + // "I inspected the authorization response and it had no iss" by passing null. + it('rejects when the AS advertises iss support but the inspected response lacks iss (null)', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, null) + ).toThrow(/did not include an iss parameter/); + }); + + // undefined means the caller never had access to the authorization response + // parameters, so the SDK cannot fail closed on the caller's behalf. + it('skips validation entirely when the caller provides no response parameters (undefined)', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, undefined) + ).not.toThrow(); + expect(() => validateAuthorizationResponseIssuer({ issuer }, undefined)).not.toThrow(); + expect(() => validateAuthorizationResponseIssuer(undefined, undefined)).not.toThrow(); + }); + + // RFC 9207 Section 2.4, row 2: present (advertised) -> exact match required + it('accepts an exactly matching iss when support is advertised', () => { + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: true }, issuer) + ).not.toThrow(); + }); + + // RFC 9207 Section 2.4, row 2: present even without advertisement -> still compared + it('accepts an exactly matching iss even when support is not advertised', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, issuer)).not.toThrow(); + }); + + it('rejects a mismatched iss regardless of advertisement', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://attacker.example.com')).toThrow( + /does not match the expected issuer/ + ); + expect(() => + validateAuthorizationResponseIssuer( + { issuer, authorization_response_iss_parameter_supported: true }, + 'https://attacker.example.com' + ) + ).toThrow(/does not match the expected issuer/); + }); + + it('uses exact string comparison with no normalization', () => { + // Trailing slash and case differences are equivalent URLs but MUST be rejected + expect(() => validateAuthorizationResponseIssuer({ issuer }, `${issuer}/`)).toThrow(/does not match the expected issuer/); + expect(() => validateAuthorizationResponseIssuer({ issuer }, 'https://AUTH.example.com')).toThrow( + /does not match the expected issuer/ + ); + }); + + // RFC 9207 Section 2.4, row 3: neither advertised nor present -> proceed + it('proceeds when iss support is not advertised and the inspected response has no iss', () => { + expect(() => validateAuthorizationResponseIssuer({ issuer }, null)).not.toThrow(); + expect(() => + validateAuthorizationResponseIssuer({ issuer, authorization_response_iss_parameter_supported: false }, null) + ).not.toThrow(); + }); + + it('proceeds when no metadata is recorded and the inspected response has no iss', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, null)).not.toThrow(); + }); + + it('rejects when an iss is present but no metadata was recorded to validate against', () => { + expect(() => validateAuthorizationResponseIssuer(undefined, issuer)).toThrow(/no authorization server metadata was recorded/); + }); + }); + + describe('auth() with an authorization code', () => { + const resourceMetadata = { + resource: 'https://resource.example.com', + authorization_servers: [issuer] + }; + + const authServerMetadata: AuthorizationServerMetadata = { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true + }; + + function createMockProvider(metadata: AuthorizationServerMetadata = authServerMetadata): OAuthClientProvider { + return { + get redirectUrl() { + return 'http://localhost:3000/callback'; + }, + get clientMetadata() { + return { + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'test-client-id', + client_secret: 'test-client-secret' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn(), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn().mockResolvedValue('test-verifier'), + // Discovery state recorded before the redirect, including the validated issuer + discoveryState: vi.fn().mockResolvedValue({ + authorizationServerUrl: issuer, + resourceMetadata, + authorizationServerMetadata: metadata + }) + }; + } + + beforeEach(() => { + mockFetch.mockReset(); + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600 + }) + }); + } + return Promise.reject(new Error(`Unexpected fetch: ${urlString}`)); + }); + }); + + function tokenEndpointCalls(): unknown[][] { + return mockFetch.mock.calls.filter(call => call[0].toString().includes('/token')); + } + + it('exchanges the code when the response iss matches the recorded issuer', async () => { + const provider = createMockProvider(); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: issuer + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + expect(provider.saveTokens).toHaveBeenCalled(); + }); + + it('rejects a mismatched iss before the code reaches any token endpoint', async () => { + const provider = createMockProvider(); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: 'https://attacker.example.com' + }) + ).rejects.toThrow(/does not match the expected issuer/); + + expect(tokenEndpointCalls()).toHaveLength(0); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + + it('rejects cached AS metadata with a mismatched issuer before code exchange', async () => { + const provider = createMockProvider({ + ...authServerMetadata, + issuer: 'https://attacker.example.com' + }); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: 'https://attacker.example.com' + }) + ).rejects.toThrow(/Authorization server metadata issuer does not match the expected issuer/); + + expect(tokenEndpointCalls()).toHaveLength(0); + expect(provider.saveTokens).not.toHaveBeenCalled(); + }); + + it('rejects when the AS advertises iss support and the caller reports a response without iss (null)', async () => { + const provider = createMockProvider(); + + await expect( + auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123', + iss: null + }) + ).rejects.toThrow(/did not include an iss parameter/); + + expect(tokenEndpointCalls()).toHaveLength(0); + }); + + it('proceeds when iss is omitted entirely, even when the AS advertises support', async () => { + // Callers that never had access to the authorization response (legacy + // finishAuth(code) plumbing) must not be failed closed on their behalf. + const provider = createMockProvider(); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + }); + + it('proceeds without an iss when the AS does not advertise support', async () => { + const provider = createMockProvider({ + ...authServerMetadata, + authorization_response_iss_parameter_supported: undefined + }); + + const result = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: 'code123' + }); + + expect(result).toBe('AUTHORIZED'); + expect(tokenEndpointCalls()).toHaveLength(1); + }); + + it('does not surface error content from a mismatched-issuer error response', async () => { + // RFC 9207: on issuer mismatch the client MUST NOT process the rest of the + // authorization response — including error/error_description parameters. + // Simulate a forged callback carrying both a mismatched iss and attacker- + // controlled error content alongside the code. + const forgedAuthorizationResponse = { + code: 'code123', + iss: 'https://attacker.example.com', + error: 'access_denied', + error_description: 'ATTACKER CONTROLLED MESSAGE' + }; + + const provider = createMockProvider(); + + const error = await auth(provider, { + serverUrl: 'https://resource.example.com', + authorizationCode: forgedAuthorizationResponse.code, + iss: forgedAuthorizationResponse.iss + }).then( + () => { + throw new Error('expected auth() to reject'); + }, + (e: unknown) => e as Error + ); + + // Rejected for the issuer mismatch, without echoing the forged error params + expect(error.message).toMatch(/does not match the expected issuer/); + expect(error.message).not.toContain(forgedAuthorizationResponse.error_description); + expect(error.message).not.toContain('access_denied'); + + // And the code was never sent to a token endpoint + expect(tokenEndpointCalls()).toHaveLength(0); + }); + }); +}); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index f403bf80a..fbef13937 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -265,6 +265,44 @@ describe('crossAppAccess', () => { expect(String(mockFetch.mock.calls[1]![0])).toBe('https://idp.example.com/token'); }); + it('allows IdP discovery aliases whose metadata names a canonical issuer', async () => { + const mockFetch = vi.fn(); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issuer: 'https://login.microsoftonline.com/tenant-guid/v2.0', + authorization_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/authorize', + token_endpoint: 'https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token', + jwks_uri: 'https://login.microsoftonline.com/tenant-guid/discovery/v2.0/keys', + response_types_supported: ['code'], + grant_types_supported: ['urn:ietf:params:oauth:grant-type:token-exchange'] + }) + } as Response); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag', + access_token: 'jag-token', + token_type: 'N_A' + }) + } as Response); + + const result = await discoverAndRequestJwtAuthGrant({ + idpUrl: 'https://login.example-corp.com', + audience: 'https://auth.chat.example/', + resource: 'https://mcp.chat.example/', + idToken: 'id-token', + clientId: 'client', + fetchFn: mockFetch + }); + + expect(result.jwtAuthGrant).toBe('jag-token'); + expect(String(mockFetch.mock.calls[0]![0])).toBe('https://login.example-corp.com/.well-known/oauth-authorization-server'); + expect(String(mockFetch.mock.calls[1]![0])).toBe('https://login.microsoftonline.com/tenant-guid/oauth2/v2.0/token'); + }); + it('throws error when token endpoint is not discovered', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index a5e79f6c9..1bc9ec9b3 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -42,15 +42,16 @@ describe('SSEClientTransport', () => { authServer = createServer((req, res) => { if (req.url === '/.well-known/oauth-authorization-server') { + const issuer = authBaseUrl.origin; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ - issuer: 'https://auth.example.com', - authorization_endpoint: 'https://auth.example.com/authorize', - token_endpoint: 'https://auth.example.com/token', - registration_endpoint: 'https://auth.example.com/register', + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + registration_endpoint: `${issuer}/register`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) @@ -1237,7 +1238,8 @@ describe('SSEClientTransport', () => { token_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/token`, registration_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/register`, response_types_supported: ['code'], - code_challenge_methods_supported: ['S256'] + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true }) ); return; @@ -1527,6 +1529,26 @@ describe('SSEClientTransport', () => { // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); }); + + it('rejects a mismatched finishAuth iss before token exchange', async () => { + const authProviderWithCode = createMockAuthProvider({ + clientRegistered: true, + authorizationCode: 'test-auth-code' + }); + + transport = new SSEClientTransport(resourceBaseUrl, { + authProvider: authProviderWithCode, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code', { iss: 'https://attacker.example.com' })).rejects.toThrow( + /does not match the expected issuer/ + ); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(authProviderWithCode.saveTokens).not.toHaveBeenCalled(); + }); }); describe('minimal AuthProvider (non-OAuth)', () => { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 42709717e..539c756f1 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -1708,6 +1708,119 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('finishAuth iss validation (SEP-2468 / RFC 9207)', () => { + const issuer = 'http://localhost:1234'; + + function createOAuthFetchMock(): Mock { + return ( + vi + .fn() + // Protected resource metadata discovery + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + authorization_servers: [issuer], + resource: 'http://localhost:1234/mcp' + }) + }) + // OAuth metadata discovery — advertises RFC 9207 iss support + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer, + authorization_endpoint: 'http://localhost:1234/authorize', + token_endpoint: 'http://localhost:1234/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + authorization_response_iss_parameter_supported: true + }) + }) + // Code exchange + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 3600 + }) + }) + ); + } + + it('plumbs a matching iss through to validation and completes the exchange', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await transport.finishAuth('test-auth-code', { iss: issuer }); + + // The code reached the token endpoint and tokens were saved + const tokenCalls = customFetch.mock.calls.filter( + ([url, options]) => url.toString().includes('/token') && options?.method === 'POST' + ); + expect(tokenCalls).toHaveLength(1); + expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ + access_token: 'new-access-token', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'new-refresh-token' + }); + }); + + it('rejects a mismatched iss before the code reaches the token endpoint', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code', { iss: 'https://attacker.example.com' })).rejects.toThrow( + /does not match the expected issuer/ + ); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + expect(mockAuthProvider.saveTokens).not.toHaveBeenCalled(); + }); + + it('rejects when the AS advertises iss support and the caller reports a response without iss (iss: null)', async () => { + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await expect(transport.finishAuth('test-auth-code', { iss: null })).rejects.toThrow(/did not include an iss parameter/); + + const tokenCalls = customFetch.mock.calls.filter(([url]) => url.toString().includes('/token')); + expect(tokenCalls).toHaveLength(0); + }); + + it('completes the exchange when finishAuth receives no iss at all, even though the AS advertises support', async () => { + // Legacy finishAuth(code) callers cannot see the authorization response; + // the SDK must not fail closed on their behalf. + const customFetch = createOAuthFetchMock(); + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + authProvider: mockAuthProvider, + fetch: customFetch + }); + + await transport.finishAuth('test-auth-code'); + + const tokenCalls = customFetch.mock.calls.filter( + ([url, options]) => url.toString().includes('/token') && options?.method === 'POST' + ); + expect(tokenCalls).toHaveLength(1); + expect(mockAuthProvider.saveTokens).toHaveBeenCalled(); + }); + }); + describe('SSE retry field handling', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts index 4fa12a1a8..196a36750 100644 --- a/packages/codemod/src/generated/versions.ts +++ b/packages/codemod/src/generated/versions.ts @@ -1,9 +1,9 @@ // AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-alpha.2', - '@modelcontextprotocol/server': '^2.0.0-alpha.2', - '@modelcontextprotocol/node': '^2.0.0-alpha.2', - '@modelcontextprotocol/express': '^2.0.0-alpha.2', - '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.2', - '@modelcontextprotocol/core': '^2.0.0-alpha.0' + '@modelcontextprotocol/client': '^2.0.0-alpha.3', + '@modelcontextprotocol/server': '^2.0.0-alpha.3', + '@modelcontextprotocol/node': '^2.0.0-alpha.3', + '@modelcontextprotocol/express': '^2.0.0-alpha.3', + '@modelcontextprotocol/server-legacy': '^2.0.0-alpha.3', + '@modelcontextprotocol/core': '^2.0.0-alpha.1' }; diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index deee583aa..6d621ad48 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -66,7 +66,8 @@ export const OAuthMetadataSchema = z.looseObject({ introspection_endpoint_auth_methods_supported: z.array(z.string()).optional(), introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(), code_challenge_methods_supported: z.array(z.string()).optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** @@ -110,7 +111,8 @@ export const OpenIdProviderMetadataSchema = z.looseObject({ require_request_uri_registration: z.boolean().optional(), op_policy_uri: SafeUrlSchema.optional(), op_tos_uri: SafeUrlSchema.optional(), - client_id_metadata_document_supported: z.boolean().optional() + client_id_metadata_document_supported: z.boolean().optional(), + authorization_response_iss_parameter_supported: z.boolean().optional() }); /** diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index a33886429..06601d9a0 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1935,12 +1935,7 @@ export const REQUIREMENTS: Record = { behavior: 'The client rejects authorization-server metadata whose issuer does not match the URL the metadata was retrieved from (RFC 8414 section 3.3).', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'discoverAuthorizationServerMetadata never validates that the returned issuer matches the authorization-server URL the metadata was fetched from (RFC 8414 section 3.3), so spoofed-issuer metadata is accepted and the OAuth flow proceeds to registration and redirect.' - } - ] + note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-auth:prm-discovery:no-prm-fallback': { source: 'sdk',