From 7b5bfcee529bc98a1f1cf668b90167855bc28ca0 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 19:29:04 +0100 Subject: [PATCH 01/41] implementation of RFC 8707 Resource Indicators (Fixes #592, Fixes #635) --- src/client/auth.test.ts | 512 ++++++++++++++++++ src/client/auth.ts | 34 +- src/client/sse.ts | 20 +- src/client/streamableHttp.ts | 20 +- .../server/demoInMemoryOAuthProvider.test.ts | 218 ++++++++ .../server/demoInMemoryOAuthProvider.ts | 99 +++- .../server/resourceValidationExample.ts | 152 ++++++ .../server/serverUrlValidationExample.ts | 103 ++++ src/examples/server/strictModeExample.ts | 85 +++ src/server/auth/errors.ts | 10 + .../auth/handlers/authorize.config.test.ts | 361 ++++++++++++ src/server/auth/handlers/authorize.test.ts | 122 +++++ src/server/auth/handlers/authorize.ts | 38 +- src/server/auth/handlers/token.test.ts | 179 ++++++ src/server/auth/handlers/token.ts | 66 ++- src/server/auth/provider.ts | 6 +- .../auth/providers/proxyProvider.test.ts | 127 +++++ src/server/auth/providers/proxyProvider.ts | 15 +- src/server/auth/types.ts | 33 ++ src/shared/auth-utils.test.ts | 100 ++++ src/shared/auth-utils.ts | 44 ++ 21 files changed, 2311 insertions(+), 33 deletions(-) create mode 100644 src/examples/server/demoInMemoryOAuthProvider.test.ts create mode 100644 src/examples/server/resourceValidationExample.ts create mode 100644 src/examples/server/serverUrlValidationExample.ts create mode 100644 src/examples/server/strictModeExample.ts create mode 100644 src/server/auth/handlers/authorize.config.test.ts create mode 100644 src/shared/auth-utils.test.ts create mode 100644 src/shared/auth-utils.ts diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 1b9fb071..9a067405 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -341,6 +341,31 @@ describe("OAuth Authorization", () => { expect(codeVerifier).toBe("test_verifier"); }); + it("includes resource parameter when provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + resource: "https://api.example.com/mcp-server", + } + ); + + expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter when not provided", async () => { + const { authorizationUrl } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + } + ); + + expect(authorizationUrl.searchParams.has("resource")).toBe(false); + }); + it("includes scope parameter when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -489,6 +514,45 @@ describe("OAuth Authorization", () => { expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); }); + it("includes resource parameter in token exchange when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + resource: "https://api.example.com/mcp-server", + }); + + expect(tokens).toEqual(validTokens); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter from token exchange when not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokens, + }); + + await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.has("resource")).toBe(false); + }); + it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -576,6 +640,41 @@ describe("OAuth Authorization", () => { expect(body.get("client_secret")).toBe("secret123"); }); + it("includes resource parameter in refresh token request when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + const tokens = await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken: "refresh123", + resource: "https://api.example.com/mcp-server", + }); + + expect(tokens).toEqual(validTokensWithNewRefreshToken); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("excludes resource parameter from refresh token request when not provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => validTokensWithNewRefreshToken, + }); + + await refreshAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + refreshToken: "refresh123", + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.has("resource")).toBe(false); + }); + it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -807,5 +906,418 @@ describe("OAuth Authorization", () => { "https://resource.example.com/.well-known/oauth-authorization-server" ); }); + + it("canonicalizes resource URI by removing fragment", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call the auth function with a resource that has a fragment + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server#fragment", + }); + + expect(result).toBe("REDIRECT"); + + // Verify redirectToAuthorization was called with the canonicalized resource + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("passes resource parameter through authorization flow", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for authorization flow + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth without authorization code (should trigger redirect) + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the authorization URL includes the resource parameter + expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( + expect.objectContaining({ + searchParams: expect.any(URLSearchParams), + }) + ); + + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("includes resource in token exchange when authorization code is provided", async () => { + // Mock successful metadata discovery and token exchange + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "refresh123", + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token exchange + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.codeVerifier as jest.Mock).mockResolvedValue("test-verifier"); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with authorization code + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + authorizationCode: "auth-code-123", + resource: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token exchange call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("code")).toBe("auth-code-123"); + }); + + it("includes resource in token refresh", async () => { + // Mock successful metadata discovery and token refresh + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } else if (urlString.includes("/token")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: "new-access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods for token refresh + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue({ + access_token: "old-access", + refresh_token: "refresh123", + }); + (mockProvider.saveTokens as jest.Mock).mockResolvedValue(undefined); + + // Call auth with existing tokens (should trigger refresh) + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("AUTHORIZED"); + + // Find the token refresh call + const tokenCall = mockFetch.mock.calls.find(call => + call[0].toString().includes("/token") + ); + expect(tokenCall).toBeDefined(); + + const body = tokenCall![1].body as URLSearchParams; + expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh123"); + }); + + it("handles empty resource parameter", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with empty resource parameter + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "", + }); + + expect(result).toBe("REDIRECT"); + + // Verify that empty resource is not included in the URL + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.has("resource")).toBe(false); + }); + + it("handles resource with multiple fragments", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with resource containing multiple # symbols + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server#fragment#another", + }); + + expect(result).toBe("REDIRECT"); + + // Verify the resource is properly canonicalized (everything after first # removed) + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + }); + + it("verifies resource parameter distinguishes between different paths on same domain", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Test with different resource paths on same domain + // This tests the security fix that prevents token confusion between + // multiple MCP servers on the same domain + const result1 = await auth(mockProvider, { + serverUrl: "https://api.example.com", + resource: "https://api.example.com/mcp-server-1/v1", + }); + + expect(result1).toBe("REDIRECT"); + + const redirectCall1 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl1: URL = redirectCall1[0]; + expect(authUrl1.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-1/v1"); + + // Clear mock calls + (mockProvider.redirectToAuthorization as jest.Mock).mockClear(); + + // Test with different path on same domain + const result2 = await auth(mockProvider, { + serverUrl: "https://api.example.com", + resource: "https://api.example.com/mcp-server-2/v1", + }); + + expect(result2).toBe("REDIRECT"); + + const redirectCall2 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl2: URL = redirectCall2[0]; + expect(authUrl2.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-2/v1"); + + // Verify that the two resources are different (critical for security) + expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); + }); + + it("preserves query parameters in resource URI", async () => { + // Mock successful metadata discovery + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth with resource containing query parameters + const result = await auth(mockProvider, { + serverUrl: "https://resource.example.com", + resource: "https://api.example.com/mcp-server?param=value&another=test", + }); + + expect(result).toBe("REDIRECT"); + + // Verify query parameters are preserved (only fragment is removed) + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server?param=value&another=test"); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7a91eb25..9a9965f6 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,6 +2,7 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; +import { canonicalizeResourceUri } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -92,12 +93,20 @@ export async function auth( { serverUrl, authorizationCode, scope, - resourceMetadataUrl + resourceMetadataUrl, + resource }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL }): Promise { + resourceMetadataUrl?: URL; + resource?: string }): Promise { + + // Remove fragment from resource parameter if provided + let canonicalResource: string | undefined; + if (resource) { + canonicalResource = canonicalizeResourceUri(resource); + } let authorizationServerUrl = serverUrl; try { @@ -142,6 +151,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, + resource: canonicalResource, }); await provider.saveTokens(tokens); @@ -158,6 +168,7 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, + resource: canonicalResource, }); await provider.saveTokens(newTokens); @@ -176,6 +187,7 @@ export async function auth( state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, + resource: canonicalResource, }); await provider.saveCodeVerifier(codeVerifier); @@ -310,12 +322,14 @@ export async function startAuthorization( redirectUrl, scope, state, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; redirectUrl: string | URL; scope?: string; state?: string; + resource?: string; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -365,6 +379,10 @@ export async function startAuthorization( authorizationUrl.searchParams.set("scope", scope); } + if (resource) { + authorizationUrl.searchParams.set("resource", resource); + } + return { authorizationUrl, codeVerifier }; } @@ -379,12 +397,14 @@ export async function exchangeAuthorization( authorizationCode, codeVerifier, redirectUri, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; authorizationCode: string; codeVerifier: string; redirectUri: string | URL; + resource?: string; }, ): Promise { const grantType = "authorization_code"; @@ -418,6 +438,10 @@ export async function exchangeAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { @@ -442,10 +466,12 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + resource, }: { metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; + resource?: string; }, ): Promise { const grantType = "refresh_token"; @@ -477,6 +503,10 @@ export async function refreshAuthorization( params.set("client_secret", clientInformation.client_secret); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(tokenUrl, { method: "POST", headers: { diff --git a/src/client/sse.ts b/src/client/sse.ts index 5aa99abb..6c07cf25 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,6 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; +import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -86,7 +87,11 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -201,7 +206,12 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -236,7 +246,11 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 4117bb1b..e452972b 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,6 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; +import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -149,7 +150,11 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -362,7 +367,12 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + authorizationCode, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -410,7 +420,11 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + resource: extractCanonicalResourceUri(this._url) + }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts new file mode 100644 index 00000000..49c6f69b --- /dev/null +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach } from '@jest/globals'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; +import { InvalidTargetError } from '../../server/auth/errors.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; +import { Response } from 'express'; + +describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { + let provider: DemoInMemoryAuthProvider; + let clientsStore: DemoInMemoryClientsStore; + let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] }; + let mockResponse: Partial; + + beforeEach(() => { + provider = new DemoInMemoryAuthProvider(); + clientsStore = provider.clientsStore as DemoInMemoryClientsStore; + + mockClient = { + client_id: 'test-client', + client_name: 'Test Client', + client_uri: 'https://example.com', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools', + token_endpoint_auth_method: 'none', + }; + + mockResponse = { + redirect: jest.fn(), + }; + }); + + describe('Authorization with resource parameter', () => { + it('should allow authorization when no resources are configured', async () => { + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should allow authorization when resource is in allowed list', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2']; + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should reject authorization when resource is not in allowed list', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1']; + await clientsStore.registerClient(mockClient); + + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.forbidden.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + }); + + describe('Token exchange with resource validation', () => { + let authorizationCode: string; + + beforeEach(async () => { + await clientsStore.registerClient(mockClient); + + // Authorize without resource first + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + }, mockResponse as Response); + + // Extract authorization code from redirect call + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + authorizationCode = url.searchParams.get('code')!; + }); + + it('should exchange code successfully when resource matches', async () => { + // First authorize with a specific resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.example.com/v1' + ); + + expect(tokens).toHaveProperty('access_token'); + expect(tokens.token_type).toBe('bearer'); + }); + + it('should reject token exchange when resource does not match', async () => { + // First authorize with a specific resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + await expect(provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.different.com' + )).rejects.toThrow(InvalidTargetError); + }); + + it('should reject token exchange when resource was not authorized but is requested', async () => { + await expect(provider.exchangeAuthorizationCode( + mockClient, + authorizationCode, + undefined, + undefined, + 'https://api.example.com/v1' + )).rejects.toThrow(InvalidTargetError); + }); + + it('should store resource in token data', async () => { + // Authorize with resource + mockResponse.redirect = jest.fn(); + await provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/v1', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const codeWithResource = url.searchParams.get('code')!; + + const tokens = await provider.exchangeAuthorizationCode( + mockClient, + codeWithResource, + undefined, + undefined, + 'https://api.example.com/v1' + ); + + // Verify token has resource information + const tokenDetails = provider.getTokenDetails(tokens.access_token); + expect(tokenDetails?.resource).toBe('https://api.example.com/v1'); + }); + }); + + describe('Refresh token with resource validation', () => { + it('should validate resource when exchanging refresh token', async () => { + mockClient.allowed_resources = ['https://api.example.com/v1']; + await clientsStore.registerClient(mockClient); + + await expect(provider.exchangeRefreshToken( + mockClient, + 'refresh-token', + undefined, + 'https://api.forbidden.com' + )).rejects.toThrow(InvalidTargetError); + }); + }); + + describe('Allowed resources management', () => { + it('should update allowed resources for a client', async () => { + await clientsStore.registerClient(mockClient); + + // Initially no resources configured + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://any.api.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + // Set allowed resources + clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']); + + // Now should reject unauthorized resources + await expect(provider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://any.api.com', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + }); +}); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 024208d6..66583e49 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -1,23 +1,38 @@ import { randomUUID } from 'node:crypto'; import { AuthorizationParams, OAuthServerProvider } from '../../server/auth/provider.js'; import { OAuthRegisteredClientsStore } from '../../server/auth/clients.js'; -import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from 'src/shared/auth.js'; +import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../shared/auth.js'; import express, { Request, Response } from "express"; -import { AuthInfo } from 'src/server/auth/types.js'; -import { createOAuthMetadata, mcpAuthRouter } from 'src/server/auth/router.js'; +import { AuthInfo } from '../../server/auth/types.js'; +import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; +import { InvalidTargetError } from '../../server/auth/errors.js'; +interface ExtendedClientInformation extends OAuthClientInformationFull { + allowed_resources?: string[]; +} + export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); + private clients = new Map(); async getClient(clientId: string) { return this.clients.get(clientId); } - async registerClient(clientMetadata: OAuthClientInformationFull) { + async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) { this.clients.set(clientMetadata.client_id, clientMetadata); return clientMetadata; } + + /** + * Demo method to set allowed resources for a client + */ + setAllowedResources(clientId: string, resources: string[]) { + const client = this.clients.get(clientId); + if (client) { + client.allowed_resources = resources; + } + } } /** @@ -28,18 +43,28 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { * - Persistent token storage * - Rate limiting */ +interface ExtendedAuthInfo extends AuthInfo { + resource?: string; + type?: string; +} + export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); - private tokens = new Map(); + private tokens = new Map(); async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, res: Response ): Promise { + // Validate resource parameter if provided + if (params.resource) { + await this.validateResource(client, params.resource); + } + const code = randomUUID(); const searchParams = new URLSearchParams({ @@ -78,7 +103,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { authorizationCode: string, // Note: code verifier is checked in token.ts by default // it's unused here for that reason. - _codeVerifier?: string + _codeVerifier?: string, + _redirectUri?: string, + resource?: string ): Promise { const codeData = this.codes.get(authorizationCode); if (!codeData) { @@ -89,15 +116,26 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } + // Validate that the resource matches what was authorized + if (resource !== codeData.params.resource) { + throw new InvalidTargetError('Resource parameter does not match the authorized resource'); + } + + // If resource was specified during authorization, validate it's still allowed + if (codeData.params.resource) { + await this.validateResource(client, codeData.params.resource); + } + this.codes.delete(authorizationCode); const token = randomUUID(); - const tokenData = { + const tokenData: ExtendedAuthInfo = { token, clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour - type: 'access' + type: 'access', + resource: codeData.params.resource }; this.tokens.set(token, tokenData); @@ -111,11 +149,16 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } async exchangeRefreshToken( - _client: OAuthClientInformationFull, + client: OAuthClientInformationFull, _refreshToken: string, - _scopes?: string[] + _scopes?: string[], + resource?: string ): Promise { - throw new Error('Not implemented for example demo'); + // Validate resource parameter if provided + if (resource) { + await this.validateResource(client, resource); + } + throw new Error('Refresh tokens not implemented for example demo'); } async verifyAccessToken(token: string): Promise { @@ -131,6 +174,33 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { expiresAt: Math.floor(tokenData.expiresAt / 1000), }; } + + /** + * Validates that the client is allowed to access the requested resource. + * In a real implementation, this would check against a database or configuration. + */ + private async validateResource(client: OAuthClientInformationFull, resource: string): Promise { + const extendedClient = client as ExtendedClientInformation; + + // If no resources are configured, allow any resource (for demo purposes) + if (!extendedClient.allowed_resources) { + return; + } + + // Check if the requested resource is in the allowed list + if (!extendedClient.allowed_resources.includes(resource)) { + throw new InvalidTargetError( + `Client is not authorized to access resource: ${resource}` + ); + } + } + + /** + * Get token details including resource information (for demo introspection endpoint) + */ + getTokenDetails(token: string): ExtendedAuthInfo | undefined { + return this.tokens.get(token); + } } @@ -164,11 +234,14 @@ export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { } const tokenInfo = await provider.verifyAccessToken(token); + // For demo purposes, we'll add a method to get token details + const tokenDetails = provider.getTokenDetails(token); res.json({ active: true, client_id: tokenInfo.clientId, scope: tokenInfo.scopes.join(' '), - exp: tokenInfo.expiresAt + exp: tokenInfo.expiresAt, + ...(tokenDetails?.resource && { aud: tokenDetails.resource }) }); return } catch (error) { diff --git a/src/examples/server/resourceValidationExample.ts b/src/examples/server/resourceValidationExample.ts new file mode 100644 index 00000000..880b9539 --- /dev/null +++ b/src/examples/server/resourceValidationExample.ts @@ -0,0 +1,152 @@ +/** + * Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0 + * + * This example shows how to configure and use resource validation in the MCP OAuth flow. + * RFC 8707 allows OAuth clients to specify which protected resource they intend to access, + * and enables authorization servers to restrict tokens to specific resources. + */ + +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthClientInformationFull } from '../../shared/auth.js'; + +async function demonstrateResourceValidation() { + // Create the OAuth provider + const provider = new DemoInMemoryAuthProvider(); + const clientsStore = provider.clientsStore; + + // Register a client with specific allowed resources + const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = { + client_id: 'resource-aware-client', + client_name: 'Resource-Aware MCP Client', + client_uri: 'https://example.com', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools mcp:resources', + token_endpoint_auth_method: 'none', + // RFC 8707: Specify which resources this client can access + allowed_resources: [ + 'https://api.example.com/mcp/v1', + 'https://api.example.com/mcp/v2', + 'https://tools.example.com/mcp' + ] + }; + + await clientsStore.registerClient(clientWithResources); + + console.log('Registered client with allowed resources:', clientWithResources.allowed_resources); + + // Example 1: Authorization request with valid resource + try { + const mockResponse = { + redirect: (url: string) => { + console.log('āœ… Authorization successful, redirecting to:', url); + } + }; + + await provider.authorize(clientWithResources, { + codeChallenge: 'S256-challenge-here', + redirectUri: clientWithResources.redirect_uris[0], + resource: 'https://api.example.com/mcp/v1', // Valid resource + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('Authorization failed:', error); + } + + // Example 2: Authorization request with invalid resource + try { + const mockResponse = { + redirect: (url: string) => { + console.log('Redirecting to:', url); + } + }; + + await provider.authorize(clientWithResources, { + codeChallenge: 'S256-challenge-here', + redirectUri: clientWithResources.redirect_uris[0], + resource: 'https://unauthorized.api.com/mcp', // Invalid resource + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('āŒ Authorization failed as expected:', error instanceof Error ? error.message : String(error)); + } + + // Example 3: Client without resource restrictions + const openClient: OAuthClientInformationFull = { + client_id: 'open-client', + client_name: 'Open MCP Client', + client_uri: 'https://open.example.com', + redirect_uris: ['https://open.example.com/callback'], + grant_types: ['authorization_code'], + response_types: ['code'], + scope: 'mcp:tools', + token_endpoint_auth_method: 'none', + // No allowed_resources specified - can access any resource + }; + + await clientsStore.registerClient(openClient); + + try { + const mockResponse = { + redirect: (url: string) => { + console.log('āœ… Open client can access any resource, redirecting to:', url); + } + }; + + await provider.authorize(openClient, { + codeChallenge: 'S256-challenge-here', + redirectUri: openClient.redirect_uris[0], + resource: 'https://any.api.com/mcp', // Any resource is allowed + scopes: ['mcp:tools'] + }, mockResponse as any); + } catch (error) { + console.error('Authorization failed:', error); + } + + // Example 4: Token introspection with resource information + // First, simulate getting a token with resource restriction + const mockAuthCode = 'demo-auth-code'; + const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode); + + if (mockTokenResponse) { + const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token); + console.log('\nšŸ“‹ Token introspection result:'); + console.log('- Client ID:', tokenDetails?.clientId); + console.log('- Scopes:', tokenDetails?.scopes); + console.log('- Resource (aud):', tokenDetails?.resource); + console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction'); + } +} + +async function simulateTokenExchange( + provider: DemoInMemoryAuthProvider, + client: OAuthClientInformationFull, + authCode: string +) { + // This is a simplified simulation - in real usage, the auth code would come from the authorization flow + console.log('\nšŸ”„ Simulating token exchange with resource validation...'); + + // Note: In a real implementation, you would: + // 1. Get the authorization code from the redirect after authorize() + // 2. Exchange it for tokens using the token endpoint + // 3. The resource parameter in the token request must match the one from authorization + + return { + access_token: 'demo-token-with-resource', + token_type: 'bearer', + expires_in: 3600, + scope: 'mcp:tools' + }; +} + +// Usage instructions +console.log('šŸš€ RFC 8707 Resource Indicators Demo\n'); +console.log('This example demonstrates how to:'); +console.log('1. Register clients with allowed resources'); +console.log('2. Validate resource parameters during authorization'); +console.log('3. Include resource information in tokens'); +console.log('4. Handle invalid_target errors\n'); + +// Run the demonstration +demonstrateResourceValidation().catch(console.error); \ No newline at end of file diff --git a/src/examples/server/serverUrlValidationExample.ts b/src/examples/server/serverUrlValidationExample.ts new file mode 100644 index 00000000..e88359bc --- /dev/null +++ b/src/examples/server/serverUrlValidationExample.ts @@ -0,0 +1,103 @@ +/** + * Example demonstrating server URL validation for RFC 8707 compliance + * + * This example shows how to configure an OAuth server to validate that + * the resource parameter in requests matches the server's own URL, + * ensuring tokens are only issued for this specific server. + */ + +import express from 'express'; +import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; +import { tokenHandler } from '../../server/auth/handlers/token.js'; +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthServerConfig } from '../../server/auth/types.js'; + +// The canonical URL where this MCP server is accessible +const SERVER_URL = 'https://api.example.com/mcp'; + +// Configuration that validates resource matches this server +const serverValidationConfig: OAuthServerConfig = { + // The server's canonical URL (without fragment) + serverUrl: SERVER_URL, + + // Enable validation that resource parameter matches serverUrl + // This also makes the resource parameter required + validateResourceMatchesServer: true +}; + +// Create the OAuth provider +const provider = new DemoInMemoryAuthProvider(); + +// Create Express app +const app = express(); + +// Configure authorization endpoint with server URL validation +app.use('/oauth/authorize', authorizationHandler({ + provider, + config: serverValidationConfig +})); + +// Configure token endpoint with server URL validation +app.use('/oauth/token', tokenHandler({ + provider, + config: serverValidationConfig +})); + +// Example scenarios +console.log('šŸ” Server URL Validation Example\n'); +console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`); + +console.log('āœ… Valid request examples:'); +console.log(`1. Resource matches server URL: + GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&... + Result: Authorization proceeds normally\n`); + +console.log(`2. Resource with query parameters (exact match required): + GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&... + Result: Rejected - resource must match exactly\n`); + +console.log('āŒ Invalid request examples:'); +console.log(`1. Different domain: + GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&... + Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`); + +console.log(`2. Different path: + GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&... + Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`); + +console.log(`3. Missing resource (with validateResourceMatchesServer: true): + GET /oauth/authorize?client_id=my-client&... + Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`); + +console.log('šŸ›”ļø Security Benefits:'); +console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers'); +console.log('2. Ensures all tokens are scoped to this specific MCP server'); +console.log('3. Provides clear audit trail of resource access attempts'); +console.log('4. Protects against malicious clients trying to obtain tokens for other services\n'); + +console.log('šŸ“ Configuration Notes:'); +console.log('- serverUrl should be the exact URL clients use to connect'); +console.log('- Fragments are automatically removed from both serverUrl and resource'); +console.log('- When validateResourceMatchesServer is true, resource parameter is required'); +console.log('- Validation ensures exact match between resource and serverUrl\n'); + +console.log('šŸ”§ Implementation Tips:'); +console.log('1. Set serverUrl from environment variable for different deployments:'); +console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n'); + +console.log('2. For development environments, you might disable validation:'); +console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n'); + +console.log('3. Consider logging failed validation attempts for security monitoring:'); +console.log(' Monitor logs for patterns of invalid_target errors\n'); + +// Example of dynamic configuration based on environment +const productionConfig: OAuthServerConfig = { + serverUrl: process.env.MCP_SERVER_URL || SERVER_URL, + validateResourceMatchesServer: process.env.NODE_ENV === 'production' +}; + +console.log('šŸš€ Production configuration example:'); +console.log(JSON.stringify(productionConfig, null, 2)); + +export { app, provider, serverValidationConfig }; \ No newline at end of file diff --git a/src/examples/server/strictModeExample.ts b/src/examples/server/strictModeExample.ts new file mode 100644 index 00000000..5ff140d6 --- /dev/null +++ b/src/examples/server/strictModeExample.ts @@ -0,0 +1,85 @@ +/** + * Example demonstrating strict RFC 8707 enforcement mode + * + * This example shows how to configure an OAuth server that requires + * all requests to include a resource parameter, ensuring maximum + * security against token confusion attacks. + */ + +import express from 'express'; +import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; +import { tokenHandler } from '../../server/auth/handlers/token.js'; +import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; +import { OAuthServerConfig } from '../../server/auth/types.js'; + +// Strict mode configuration - validates resource matches server URL +const SERVER_URL = 'https://api.example.com/mcp'; +const strictConfig: OAuthServerConfig = { + serverUrl: SERVER_URL, + validateResourceMatchesServer: true +}; + +// Create the OAuth provider +const provider = new DemoInMemoryAuthProvider(); + +// Create Express app +const app = express(); + +// Configure authorization endpoint with strict mode +app.use('/oauth/authorize', authorizationHandler({ + provider, + config: strictConfig, + rateLimit: { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 10 // limit each IP to 10 requests per window + } +})); + +// Configure token endpoint with strict mode +app.use('/oauth/token', tokenHandler({ + provider, + config: strictConfig, + rateLimit: { + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20 // limit each IP to 20 requests per window + } +})); + +// Example of what happens with different requests: +console.log('šŸ”’ Strict RFC 8707 Mode Example\n'); +console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`); + +console.log('āœ… Valid request example:'); +console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`); + +console.log('āŒ Invalid request examples:'); +console.log('1. Missing resource:'); +console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256'); +console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n'); + +console.log('2. Wrong resource:'); +console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp'); +console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`); + +console.log('šŸ“‹ Benefits of server URL validation:'); +console.log('1. Prevents token confusion attacks - tokens can only be issued for this server'); +console.log('2. Ensures all tokens are properly scoped to this specific MCP server'); +console.log('3. No accidental token leakage to other services'); +console.log('4. Clear security boundary enforcement\n'); + +console.log('āš ļø Migration considerations:'); +console.log('1. Server must know its canonical URL (configure via environment variable)'); +console.log('2. All clients must send the exact matching resource parameter'); +console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)'); +console.log('4. Monitor logs to track adoption before enabling validation\n'); + +// Example middleware to track resource parameter usage +app.use((req, res, next) => { + if (req.path.includes('/oauth/')) { + const hasResource = req.query.resource || req.body?.resource; + console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`); + } + next(); +}); + +export { app, provider, strictConfig }; \ No newline at end of file diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 428199ce..5c001bcd 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -189,3 +189,13 @@ export class InsufficientScopeError extends OAuthError { super("insufficient_scope", message, errorUri); } } + +/** + * Invalid target error - The requested resource is invalid, unknown, or malformed. + * (RFC 8707 - Resource Indicators for OAuth 2.0) + */ +export class InvalidTargetError extends OAuthError { + constructor(message: string, errorUri?: string) { + super("invalid_target", message, errorUri); + } +} diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts new file mode 100644 index 00000000..aa180c4b --- /dev/null +++ b/src/server/auth/handlers/authorize.config.test.ts @@ -0,0 +1,361 @@ +import express from "express"; +import request from "supertest"; +import { authorizationHandler } from "./authorize.js"; +import { OAuthServerProvider } from "../provider.js"; +import { OAuthServerConfig } from "../types.js"; +import { InvalidRequestError, InvalidTargetError } from "../errors.js"; + +describe("Authorization handler with config", () => { + let app: express.Application; + let mockProvider: jest.Mocked; + + beforeEach(() => { + app = express(); + + const mockClientsStore = { + getClient: jest.fn(), + registerClient: jest.fn(), + }; + + mockProvider = { + clientsStore: mockClientsStore, + authorize: jest.fn(), + exchangeAuthorizationCode: jest.fn(), + exchangeRefreshToken: jest.fn(), + challengeForAuthorizationCode: jest.fn(), + verifyAccessToken: jest.fn(), + } as jest.Mocked; + }); + + describe("validateResourceMatchesServer configuration", () => { + it("should throw error when validateResourceMatchesServer is true but serverUrl is not set", () => { + const invalidConfig: OAuthServerConfig = { + validateResourceMatchesServer: true + // serverUrl is missing + }; + + expect(() => { + authorizationHandler({ + provider: mockProvider, + config: invalidConfig + }); + }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + }); + }); + + describe("server URL validation (validateResourceMatchesServer: true)", () => { + const serverValidationConfig: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp", + validateResourceMatchesServer: true + }; + + beforeEach(() => { + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: serverValidationConfig + })); + }); + + it("should reject requests without resource parameter", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_request"); + expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); + }); + + it("should accept requests with resource parameter", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + expect(mockProvider.authorize).toHaveBeenCalledWith( + mockClient, + expect.objectContaining({ + resource: "https://api.example.com/mcp" + }), + expect.any(Object) + ); + }); + }); + + describe("warning mode (default behavior)", () => { + const warnConfig: OAuthServerConfig = { + // No configuration needed - warnings are always enabled by default + }; + + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: warnConfig + })); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it("should log warning when resource is missing", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("test-client is missing the resource parameter") + ); + }); + + it("should not log warning when resource is present", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + // Note: No silent mode test anymore - warnings are always enabled + + describe("server URL validation (validateResourceMatchesServer: true)", () => { + const serverValidationConfig: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp", + validateResourceMatchesServer: true + }; + + beforeEach(() => { + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: serverValidationConfig + })); + }); + + it("should accept requests when resource matches server URL", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + }); + + it("should reject requests when resource does not match server URL", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://different.api.com/mcp" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_target"); + expect(response.headers.location).toContain("does+not+match+this+server"); + }); + + it("should reject requests without resource parameter when validation is enabled", async () => { + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state" + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("error=invalid_request"); + expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); + }); + + it("should handle server URL with fragment correctly", async () => { + // Reconfigure with a server URL that has a fragment (though it shouldn't) + const configWithFragment: OAuthServerConfig = { + serverUrl: "https://api.example.com/mcp#fragment", + validateResourceMatchesServer: true + }; + + app = express(); + app.use("/oauth/authorize", authorizationHandler({ + provider: mockProvider, + config: configWithFragment + })); + + const mockClient = { + client_id: "test-client", + client_name: "Test Client", + redirect_uris: ["https://example.com/callback"], + grant_types: ["authorization_code"], + response_types: ["code"], + scope: "read write", + token_endpoint_auth_method: "none", + }; + + (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); + mockProvider.authorize.mockImplementation(async (client, params, res) => { + res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); + }); + + const response = await request(app) + .get("/oauth/authorize") + .query({ + client_id: "test-client", + redirect_uri: "https://example.com/callback", + response_type: "code", + code_challenge: "test-challenge", + code_challenge_method: "S256", + state: "test-state", + resource: "https://api.example.com/mcp" // No fragment + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toContain("code=auth-code"); + }); + }); +}); \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index e921d5ea..20a2af89 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -276,6 +276,128 @@ describe('Authorization Handler', () => { }); }); + describe('Resource parameter validation', () => { + it('accepts valid resource parameter', async () => { + const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api.example.com/resource', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + + it('rejects invalid resource parameter (non-URL)', async () => { + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'not-a-url' + }); + + expect(response.status).toBe(302); + const location = new URL(response.header.location); + expect(location.searchParams.get('error')).toBe('invalid_request'); + expect(location.searchParams.get('error_description')).toContain('resource'); + }); + + it('handles authorization without resource parameter', async () => { + const mockProviderWithoutResource = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithoutResource).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: undefined, + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123' + }), + expect.any(Object) + ); + }); + + it('passes multiple resources if provided', async () => { + const mockProviderWithResources = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api1.example.com/resource', + state: 'test-state' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithResources).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api1.example.com/resource', + state: 'test-state' + }), + expect.any(Object) + ); + }); + + it('validates resource parameter in POST requests', async () => { + const mockProviderPost = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .post('/authorize') + .type('form') + .send({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(302); + expect(mockProviderPost).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + resource: 'https://api.example.com/resource' + }), + expect.any(Object) + ); + }); + }); + describe('Successful authorization', () => { it('handles successful authorization with all parameters', async () => { const response = await supertest(app) diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 3e9a336b..dbed1b52 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -8,10 +8,12 @@ import { InvalidRequestError, InvalidClientError, InvalidScopeError, + InvalidTargetError, ServerError, TooManyRequestsError, OAuthError } from "../errors.js"; +import { OAuthServerConfig } from "../types.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -20,6 +22,10 @@ export type AuthorizationHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; + /** + * OAuth server configuration options + */ + config?: OAuthServerConfig; }; // Parameters that must be validated in order to issue redirects. @@ -35,9 +41,15 @@ const RequestAuthorizationParamsSchema = z.object({ code_challenge_method: z.literal("S256"), scope: z.string().optional(), state: z.string().optional(), + resource: z.string().url().optional(), }); -export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { +export function authorizationHandler({ provider, rateLimit: rateLimitConfig, config }: AuthorizationHandlerOptions): RequestHandler { + // Validate configuration + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + // Create a router to apply middleware const router = express.Router(); router.use(allowedMethods(["GET", "POST"])); @@ -115,9 +127,30 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A throw new InvalidRequestError(parseResult.error.message); } - const { scope, code_challenge } = parseResult.data; + const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + // Remove fragment from server URL if present (though it shouldn't have one) + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Authorization request from client ${client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } + // Validate scopes let requestedScopes: string[] = []; if (scope !== undefined) { @@ -138,6 +171,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, + resource, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index c165fe7f..68794c36 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -282,6 +282,99 @@ describe('Token Handler', () => { expect(response.body.refresh_token).toBe('mock_refresh_token'); }); + it('accepts and passes resource parameter to provider', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + 'https://api.example.com/resource' // resource parameter + ); + }); + + it('rejects invalid resource parameter (non-URL)', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + resource: 'not-a-url' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('resource'); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + undefined, // redirect_uri + undefined // resource parameter + ); + }); + + it('passes resource with redirect_uri', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'authorization_code', + code: 'valid_code', + code_verifier: 'valid_verifier', + redirect_uri: 'https://example.com/callback', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeCode).toHaveBeenCalledWith( + validClient, + 'valid_code', + undefined, // code_verifier is undefined after PKCE validation + 'https://example.com/callback', // redirect_uri + 'https://api.example.com/resource' // resource parameter + ); + }); + it('passes through code verifier when using proxy provider', async () => { const originalFetch = global.fetch; @@ -472,6 +565,92 @@ describe('Token Handler', () => { expect(response.status).toBe(200); expect(response.body.scope).toBe('profile email'); }); + + it('accepts and passes resource parameter to provider on refresh', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + 'https://api.example.com/resource' // resource parameter + ); + }); + + it('rejects invalid resource parameter (non-URL) on refresh', async () => { + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + resource: 'not-a-url' + }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe('invalid_request'); + expect(response.body.error_description).toContain('resource'); + }); + + it('handles refresh token exchange without resource parameter', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + undefined, // scopes + undefined // resource parameter + ); + }); + + it('passes resource with scopes on refresh', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); + + const response = await supertest(app) + .post('/token') + .type('form') + .send({ + client_id: 'valid-client', + client_secret: 'valid-secret', + grant_type: 'refresh_token', + refresh_token: 'valid_refresh_token', + scope: 'profile email', + resource: 'https://api.example.com/resource' + }); + + expect(response.status).toBe(200); + expect(mockExchangeRefresh).toHaveBeenCalledWith( + validClient, + 'valid_refresh_token', + ['profile', 'email'], // scopes + 'https://api.example.com/resource' // resource parameter + ); + }); }); describe('CORS support', () => { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index eadbd751..37950502 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -12,8 +12,10 @@ import { UnsupportedGrantTypeError, ServerError, TooManyRequestsError, - OAuthError + OAuthError, + InvalidTargetError } from "../errors.js"; +import { OAuthServerConfig } from "../types.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -22,6 +24,10 @@ export type TokenHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; + /** + * OAuth server configuration options + */ + config?: OAuthServerConfig; }; const TokenRequestSchema = z.object({ @@ -32,14 +38,21 @@ const AuthorizationCodeGrantSchema = z.object({ code: z.string(), code_verifier: z.string(), redirect_uri: z.string().optional(), + resource: z.string().url().optional(), }); const RefreshTokenGrantSchema = z.object({ refresh_token: z.string(), scope: z.string().optional(), + resource: z.string().url().optional(), }); -export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { +export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: TokenHandlerOptions): RequestHandler { + // Validate configuration + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -89,7 +102,27 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { code, code_verifier, redirect_uri } = parseResult.data; + const { code, code_verifier, redirect_uri, resource } = parseResult.data; + + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -107,7 +140,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand client, code, skipLocalPkceValidation ? code_verifier : undefined, - redirect_uri + redirect_uri, + resource ); res.status(200).json(tokens); break; @@ -119,10 +153,30 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand throw new InvalidRequestError(parseResult.error.message); } - const { refresh_token, scope } = parseResult.data; + const { refresh_token, scope, resource } = parseResult.data; + + // If validateResourceMatchesServer is enabled, resource is required and must match + if (config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = config.serverUrl!.split('#')[0]; + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else { + // Always log warning if resource is missing (unless validation is enabled) + if (!resource) { + console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + } const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); res.status(200).json(tokens); break; } diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 7815b713..25698416 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,6 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; + resource?: string; }; /** @@ -40,13 +41,14 @@ export interface OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: string ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: string): Promise; /** * Verifies an access token and returns information about it. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 69039c3e..b652390b 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -103,6 +103,49 @@ describe("Proxy OAuth Server Provider", () => { expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); + + it('includes resource parameter in authorization redirect', async () => { + await provider.authorize( + validClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + scopes: ['read', 'write'], + resource: 'https://api.example.com/resource' + }, + mockResponse + ); + + const expectedUrl = new URL('https://auth.example.com/authorize'); + expectedUrl.searchParams.set('client_id', 'test-client'); + expectedUrl.searchParams.set('response_type', 'code'); + expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); + expectedUrl.searchParams.set('code_challenge', 'test-challenge'); + expectedUrl.searchParams.set('code_challenge_method', 'S256'); + expectedUrl.searchParams.set('state', 'test-state'); + expectedUrl.searchParams.set('scope', 'read write'); + expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); + + expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); + }); + + it('handles authorization without resource parameter', async () => { + await provider.authorize( + validClient, + { + redirectUri: 'https://example.com/callback', + codeChallenge: 'test-challenge', + state: 'test-state', + scopes: ['read'] + }, + mockResponse + ); + + const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectUrl); + expect(url.searchParams.has('resource')).toBe(false); + }); }); describe("token exchange", () => { @@ -164,6 +207,41 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in authorization code exchange', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier', + 'https://example.com/callback', + 'https://api.example.com/resource' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles authorization code exchange without resource parameter', async () => { + const tokens = await provider.exchangeAuthorizationCode( + validClient, + 'test-code', + 'test-verifier' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + it("exchanges refresh token for new tokens", async () => { const tokens = await provider.exchangeRefreshToken( validClient, @@ -184,6 +262,55 @@ describe("Proxy OAuth Server Provider", () => { expect(tokens).toEqual(mockTokenResponse); }); + it('includes resource parameter in refresh token exchange', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read', 'write'], + 'https://api.example.com/resource' + ); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/token', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: expect.stringContaining('resource=' + encodeURIComponent('https://api.example.com/resource')) + }) + ); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('handles refresh token exchange without resource parameter', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['read'] + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).not.toContain('resource='); + expect(tokens).toEqual(mockTokenResponse); + }); + + it('includes both scope and resource parameters in refresh', async () => { + const tokens = await provider.exchangeRefreshToken( + validClient, + 'test-refresh-token', + ['profile', 'email'], + 'https://api.example.com/resource' + ); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const body = fetchCall[1].body as string; + expect(body).toContain('scope=profile+email'); + expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/resource')); + expect(tokens).toEqual(mockTokenResponse); + }); + }); describe("client registration", () => { diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index db7460e5..7f8b8d3d 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,6 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); + if (params.resource) searchParams.set("resource", params.resource); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -152,7 +153,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { client: OAuthClientInformationFull, authorizationCode: string, codeVerifier?: string, - redirectUri?: string + redirectUri?: string, + resource?: string ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -172,6 +174,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.append("redirect_uri", redirectUri); } + if (resource) { + params.append("resource", resource); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { @@ -192,7 +198,8 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async exchangeRefreshToken( client: OAuthClientInformationFull, refreshToken: string, - scopes?: string[] + scopes?: string[], + resource?: string ): Promise { const params = new URLSearchParams({ @@ -209,6 +216,10 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { params.set("scope", scopes.join(" ")); } + if (resource) { + params.set("resource", resource); + } + const response = await fetch(this._endpoints.tokenUrl, { method: "POST", headers: { diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b60..33ba3f86 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -27,4 +27,37 @@ export interface AuthInfo { * This field should be used for any additional data that needs to be attached to the auth info. */ extra?: Record; +} + +/** + * Configuration options for OAuth server behavior + */ +export interface OAuthServerConfig { + /** + * The canonical URL of this MCP server. When provided, the server will validate + * that the resource parameter in OAuth requests matches this URL. + * + * This should be the full URL that clients use to connect to this server, + * without any fragment component (e.g., "https://api.example.com/mcp"). + * + * Required when validateResourceMatchesServer is true. + */ + serverUrl?: string; + + /** + * If true, validates that the resource parameter matches the configured serverUrl. + * + * When enabled: + * - serverUrl must be configured (throws error if not) + * - resource parameter is required on all requests + * - resource must exactly match serverUrl (after fragment removal) + * - requests without resource parameter will be rejected with invalid_request error + * - requests with non-matching resource will be rejected with invalid_target error + * + * When disabled: + * - warnings are logged when resource parameter is missing (for migration tracking) + * + * @default false + */ + validateResourceMatchesServer?: boolean; } \ No newline at end of file diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts new file mode 100644 index 00000000..1c45511a --- /dev/null +++ b/src/shared/auth-utils.test.ts @@ -0,0 +1,100 @@ +import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; + +describe('auth-utils', () => { + describe('resourceUrlFromServerUrl', () => { + it('should remove fragments', () => { + expect(resourceUrlFromServerUrl('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl('https://example.com#fragment')).toBe('https://example.com'); + expect(resourceUrlFromServerUrl('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + }); + + it('should return URL unchanged if no fragment', () => { + expect(resourceUrlFromServerUrl('https://example.com')).toBe('https://example.com'); + expect(resourceUrlFromServerUrl('https://example.com/path')).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Case sensitivity preserved + expect(resourceUrlFromServerUrl('HTTPS://EXAMPLE.COM/PATH')).toBe('HTTPS://EXAMPLE.COM/PATH'); + // Ports preserved + expect(resourceUrlFromServerUrl('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(resourceUrlFromServerUrl('https://example.com:8080/path')).toBe('https://example.com:8080/path'); + // Query parameters preserved + expect(resourceUrlFromServerUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com?foo=bar&baz=qux'); + // Trailing slashes preserved + expect(resourceUrlFromServerUrl('https://example.com/')).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl('https://example.com/path/')).toBe('https://example.com/path/'); + }); + }); + + describe('canonicalizeResourceUri', () => { + it('should remove fragments', () => { + expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + }); + + it('should keep everything else unchanged', () => { + expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + }); + }); + + describe('validateResourceUri', () => { + it('should accept valid resource URIs without fragments', () => { + expect(() => validateResourceUri('https://example.com')).not.toThrow(); + expect(() => validateResourceUri('https://example.com/path')).not.toThrow(); + expect(() => validateResourceUri('http://example.com:8080')).not.toThrow(); + expect(() => validateResourceUri('https://example.com?query=1')).not.toThrow(); + expect(() => validateResourceUri('ftp://example.com')).not.toThrow(); // Only fragment check now + }); + + it('should reject URIs with fragments', () => { + expect(() => validateResourceUri('https://example.com#fragment')).toThrow('must not contain a fragment'); + expect(() => validateResourceUri('https://example.com/path#section')).toThrow('must not contain a fragment'); + expect(() => validateResourceUri('https://example.com?query=1#anchor')).toThrow('must not contain a fragment'); + }); + + it('should accept any URI without fragment', () => { + // These are all valid now since we only check for fragments + expect(() => validateResourceUri('//example.com')).not.toThrow(); + expect(() => validateResourceUri('https://user:pass@example.com')).not.toThrow(); + expect(() => validateResourceUri('/path')).not.toThrow(); + expect(() => validateResourceUri('path')).not.toThrow(); + }); + }); + + describe('extractCanonicalResourceUri', () => { + it('should remove fragments from URLs', () => { + expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + }); + + it('should handle URL object', () => { + const url = new URL('https://example.com:8443/path?query=1#fragment'); + expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1'); + }); + + it('should keep everything else unchanged', () => { + // Preserves case + expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + // Preserves all ports + expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); + // Preserves query parameters + expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + // Preserves trailing slashes + expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/'); + expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); + }); + + it('should distinguish between different paths on same domain', () => { + // This is the key test for the security concern mentioned + const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1'); + const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2'); + expect(app1).not.toBe(app2); + expect(app1).toBe('https://api.example.com/mcp-server-1'); + expect(app2).toBe('https://api.example.com/mcp-server-2'); + }); + }); +}); \ No newline at end of file diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts new file mode 100644 index 00000000..aed5f247 --- /dev/null +++ b/src/shared/auth-utils.ts @@ -0,0 +1,44 @@ +/** + * Utilities for handling OAuth resource URIs according to RFC 8707. + */ + +/** + * Converts a server URL to a resource URL by removing the fragment. + * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". + * Keeps everything else unchanged (scheme, domain, port, path, query). + */ +export function resourceUrlFromServerUrl(url: string): string { + const hashIndex = url.indexOf('#'); + return hashIndex === -1 ? url : url.substring(0, hashIndex); +} + +/** + * Validates a resource URI according to RFC 8707 requirements. + * @param resourceUri The resource URI to validate + * @throws Error if the URI contains a fragment + */ +export function validateResourceUri(resourceUri: string): void { + if (resourceUri.includes('#')) { + throw new Error(`Invalid resource URI: ${resourceUri} - must not contain a fragment`); + } +} + +/** + * Removes fragment from URI to make it RFC 8707 compliant. + * @deprecated Use resourceUrlFromServerUrl instead + */ +export function canonicalizeResourceUri(resourceUri: string): string { + return resourceUrlFromServerUrl(resourceUri); +} + +/** + * Extracts resource URI from server URL by removing fragment. + * @param serverUrl The server URL to extract from + * @returns The resource URI without fragment + */ +export function extractResourceUri(serverUrl: string | URL): string { + return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); +} + +// Backward compatibility alias +export const extractCanonicalResourceUri = extractResourceUri; \ No newline at end of file From 1f4e42c09ea8c42db79afa63d2abea74a57473a1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 20:05:02 +0100 Subject: [PATCH 02/41] cleanup auth-utils and remove example files Co-Authored-By: Claude --- src/client/auth.ts | 4 +- src/client/sse.ts | 8 +- src/client/streamableHttp.ts | 8 +- .../server/resourceValidationExample.ts | 152 ------------------ .../server/serverUrlValidationExample.ts | 103 ------------ src/examples/server/strictModeExample.ts | 85 ---------- src/server/auth/handlers/authorize.ts | 3 +- src/server/auth/handlers/token.ts | 5 +- src/shared/auth-utils.test.ts | 37 ++--- src/shared/auth-utils.ts | 13 +- 10 files changed, 29 insertions(+), 389 deletions(-) delete mode 100644 src/examples/server/resourceValidationExample.ts delete mode 100644 src/examples/server/serverUrlValidationExample.ts delete mode 100644 src/examples/server/strictModeExample.ts diff --git a/src/client/auth.ts b/src/client/auth.ts index 9a9965f6..28188b7c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -2,7 +2,7 @@ import pkceChallenge from "pkce-challenge"; import { LATEST_PROTOCOL_VERSION } from "../types.js"; import type { OAuthClientMetadata, OAuthClientInformation, OAuthTokens, OAuthMetadata, OAuthClientInformationFull, OAuthProtectedResourceMetadata } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; -import { canonicalizeResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -105,7 +105,7 @@ export async function auth( // Remove fragment from resource parameter if provided let canonicalResource: string | undefined; if (resource) { - canonicalResource = canonicalizeResourceUri(resource); + canonicalResource = resourceUrlFromServerUrl(resource); } let authorizationServerUrl = serverUrl; diff --git a/src/client/sse.ts b/src/client/sse.ts index 6c07cf25..c484bde9 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; +import { extractResourceUri } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -90,7 +90,7 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +210,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +249,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index e452972b..25c41bf3 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { extractCanonicalResourceUri } from "../shared/auth-utils.js"; +import { extractResourceUri } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -153,7 +153,7 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +371,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractCanonicalResourceUri(this._url) + resource: extractResourceUri(this._url) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/examples/server/resourceValidationExample.ts b/src/examples/server/resourceValidationExample.ts deleted file mode 100644 index 880b9539..00000000 --- a/src/examples/server/resourceValidationExample.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Example demonstrating RFC 8707 Resource Indicators for OAuth 2.0 - * - * This example shows how to configure and use resource validation in the MCP OAuth flow. - * RFC 8707 allows OAuth clients to specify which protected resource they intend to access, - * and enables authorization servers to restrict tokens to specific resources. - */ - -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthClientInformationFull } from '../../shared/auth.js'; - -async function demonstrateResourceValidation() { - // Create the OAuth provider - const provider = new DemoInMemoryAuthProvider(); - const clientsStore = provider.clientsStore; - - // Register a client with specific allowed resources - const clientWithResources: OAuthClientInformationFull & { allowed_resources?: string[] } = { - client_id: 'resource-aware-client', - client_name: 'Resource-Aware MCP Client', - client_uri: 'https://example.com', - redirect_uris: ['https://example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools mcp:resources', - token_endpoint_auth_method: 'none', - // RFC 8707: Specify which resources this client can access - allowed_resources: [ - 'https://api.example.com/mcp/v1', - 'https://api.example.com/mcp/v2', - 'https://tools.example.com/mcp' - ] - }; - - await clientsStore.registerClient(clientWithResources); - - console.log('Registered client with allowed resources:', clientWithResources.allowed_resources); - - // Example 1: Authorization request with valid resource - try { - const mockResponse = { - redirect: (url: string) => { - console.log('āœ… Authorization successful, redirecting to:', url); - } - }; - - await provider.authorize(clientWithResources, { - codeChallenge: 'S256-challenge-here', - redirectUri: clientWithResources.redirect_uris[0], - resource: 'https://api.example.com/mcp/v1', // Valid resource - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('Authorization failed:', error); - } - - // Example 2: Authorization request with invalid resource - try { - const mockResponse = { - redirect: (url: string) => { - console.log('Redirecting to:', url); - } - }; - - await provider.authorize(clientWithResources, { - codeChallenge: 'S256-challenge-here', - redirectUri: clientWithResources.redirect_uris[0], - resource: 'https://unauthorized.api.com/mcp', // Invalid resource - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('āŒ Authorization failed as expected:', error instanceof Error ? error.message : String(error)); - } - - // Example 3: Client without resource restrictions - const openClient: OAuthClientInformationFull = { - client_id: 'open-client', - client_name: 'Open MCP Client', - client_uri: 'https://open.example.com', - redirect_uris: ['https://open.example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools', - token_endpoint_auth_method: 'none', - // No allowed_resources specified - can access any resource - }; - - await clientsStore.registerClient(openClient); - - try { - const mockResponse = { - redirect: (url: string) => { - console.log('āœ… Open client can access any resource, redirecting to:', url); - } - }; - - await provider.authorize(openClient, { - codeChallenge: 'S256-challenge-here', - redirectUri: openClient.redirect_uris[0], - resource: 'https://any.api.com/mcp', // Any resource is allowed - scopes: ['mcp:tools'] - }, mockResponse as any); - } catch (error) { - console.error('Authorization failed:', error); - } - - // Example 4: Token introspection with resource information - // First, simulate getting a token with resource restriction - const mockAuthCode = 'demo-auth-code'; - const mockTokenResponse = await simulateTokenExchange(provider, clientWithResources, mockAuthCode); - - if (mockTokenResponse) { - const tokenDetails = provider.getTokenDetails(mockTokenResponse.access_token); - console.log('\nšŸ“‹ Token introspection result:'); - console.log('- Client ID:', tokenDetails?.clientId); - console.log('- Scopes:', tokenDetails?.scopes); - console.log('- Resource (aud):', tokenDetails?.resource); - console.log('- Token is restricted to:', tokenDetails?.resource || 'No resource restriction'); - } -} - -async function simulateTokenExchange( - provider: DemoInMemoryAuthProvider, - client: OAuthClientInformationFull, - authCode: string -) { - // This is a simplified simulation - in real usage, the auth code would come from the authorization flow - console.log('\nšŸ”„ Simulating token exchange with resource validation...'); - - // Note: In a real implementation, you would: - // 1. Get the authorization code from the redirect after authorize() - // 2. Exchange it for tokens using the token endpoint - // 3. The resource parameter in the token request must match the one from authorization - - return { - access_token: 'demo-token-with-resource', - token_type: 'bearer', - expires_in: 3600, - scope: 'mcp:tools' - }; -} - -// Usage instructions -console.log('šŸš€ RFC 8707 Resource Indicators Demo\n'); -console.log('This example demonstrates how to:'); -console.log('1. Register clients with allowed resources'); -console.log('2. Validate resource parameters during authorization'); -console.log('3. Include resource information in tokens'); -console.log('4. Handle invalid_target errors\n'); - -// Run the demonstration -demonstrateResourceValidation().catch(console.error); \ No newline at end of file diff --git a/src/examples/server/serverUrlValidationExample.ts b/src/examples/server/serverUrlValidationExample.ts deleted file mode 100644 index e88359bc..00000000 --- a/src/examples/server/serverUrlValidationExample.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Example demonstrating server URL validation for RFC 8707 compliance - * - * This example shows how to configure an OAuth server to validate that - * the resource parameter in requests matches the server's own URL, - * ensuring tokens are only issued for this specific server. - */ - -import express from 'express'; -import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; -import { tokenHandler } from '../../server/auth/handlers/token.js'; -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthServerConfig } from '../../server/auth/types.js'; - -// The canonical URL where this MCP server is accessible -const SERVER_URL = 'https://api.example.com/mcp'; - -// Configuration that validates resource matches this server -const serverValidationConfig: OAuthServerConfig = { - // The server's canonical URL (without fragment) - serverUrl: SERVER_URL, - - // Enable validation that resource parameter matches serverUrl - // This also makes the resource parameter required - validateResourceMatchesServer: true -}; - -// Create the OAuth provider -const provider = new DemoInMemoryAuthProvider(); - -// Create Express app -const app = express(); - -// Configure authorization endpoint with server URL validation -app.use('/oauth/authorize', authorizationHandler({ - provider, - config: serverValidationConfig -})); - -// Configure token endpoint with server URL validation -app.use('/oauth/token', tokenHandler({ - provider, - config: serverValidationConfig -})); - -// Example scenarios -console.log('šŸ” Server URL Validation Example\n'); -console.log(`This server only accepts resource parameters matching: ${SERVER_URL}\n`); - -console.log('āœ… Valid request examples:'); -console.log(`1. Resource matches server URL: - GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}&... - Result: Authorization proceeds normally\n`); - -console.log(`2. Resource with query parameters (exact match required): - GET /oauth/authorize?client_id=my-client&resource=${SERVER_URL}?version=2&... - Result: Rejected - resource must match exactly\n`); - -console.log('āŒ Invalid request examples:'); -console.log(`1. Different domain: - GET /oauth/authorize?client_id=my-client&resource=https://evil.com/mcp&... - Response: 400 invalid_target - "Resource parameter 'https://evil.com/mcp' does not match this server's URL"\n`); - -console.log(`2. Different path: - GET /oauth/authorize?client_id=my-client&resource=https://api.example.com/different&... - Response: 400 invalid_target - "Resource parameter does not match this server's URL"\n`); - -console.log(`3. Missing resource (with validateResourceMatchesServer: true): - GET /oauth/authorize?client_id=my-client&... - Response: 400 invalid_request - "Resource parameter is required when server URL validation is enabled"\n`); - -console.log('šŸ›”ļø Security Benefits:'); -console.log('1. Prevents token confusion attacks - tokens cannot be obtained for other servers'); -console.log('2. Ensures all tokens are scoped to this specific MCP server'); -console.log('3. Provides clear audit trail of resource access attempts'); -console.log('4. Protects against malicious clients trying to obtain tokens for other services\n'); - -console.log('šŸ“ Configuration Notes:'); -console.log('- serverUrl should be the exact URL clients use to connect'); -console.log('- Fragments are automatically removed from both serverUrl and resource'); -console.log('- When validateResourceMatchesServer is true, resource parameter is required'); -console.log('- Validation ensures exact match between resource and serverUrl\n'); - -console.log('šŸ”§ Implementation Tips:'); -console.log('1. Set serverUrl from environment variable for different deployments:'); -console.log(' serverUrl: process.env.MCP_SERVER_URL || "https://api.example.com/mcp"\n'); - -console.log('2. For development environments, you might disable validation:'); -console.log(' validateResourceMatchesServer: process.env.NODE_ENV === "production"\n'); - -console.log('3. Consider logging failed validation attempts for security monitoring:'); -console.log(' Monitor logs for patterns of invalid_target errors\n'); - -// Example of dynamic configuration based on environment -const productionConfig: OAuthServerConfig = { - serverUrl: process.env.MCP_SERVER_URL || SERVER_URL, - validateResourceMatchesServer: process.env.NODE_ENV === 'production' -}; - -console.log('šŸš€ Production configuration example:'); -console.log(JSON.stringify(productionConfig, null, 2)); - -export { app, provider, serverValidationConfig }; \ No newline at end of file diff --git a/src/examples/server/strictModeExample.ts b/src/examples/server/strictModeExample.ts deleted file mode 100644 index 5ff140d6..00000000 --- a/src/examples/server/strictModeExample.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Example demonstrating strict RFC 8707 enforcement mode - * - * This example shows how to configure an OAuth server that requires - * all requests to include a resource parameter, ensuring maximum - * security against token confusion attacks. - */ - -import express from 'express'; -import { authorizationHandler } from '../../server/auth/handlers/authorize.js'; -import { tokenHandler } from '../../server/auth/handlers/token.js'; -import { DemoInMemoryAuthProvider } from './demoInMemoryOAuthProvider.js'; -import { OAuthServerConfig } from '../../server/auth/types.js'; - -// Strict mode configuration - validates resource matches server URL -const SERVER_URL = 'https://api.example.com/mcp'; -const strictConfig: OAuthServerConfig = { - serverUrl: SERVER_URL, - validateResourceMatchesServer: true -}; - -// Create the OAuth provider -const provider = new DemoInMemoryAuthProvider(); - -// Create Express app -const app = express(); - -// Configure authorization endpoint with strict mode -app.use('/oauth/authorize', authorizationHandler({ - provider, - config: strictConfig, - rateLimit: { - windowMs: 15 * 60 * 1000, // 15 minutes - max: 10 // limit each IP to 10 requests per window - } -})); - -// Configure token endpoint with strict mode -app.use('/oauth/token', tokenHandler({ - provider, - config: strictConfig, - rateLimit: { - windowMs: 15 * 60 * 1000, // 15 minutes - max: 20 // limit each IP to 20 requests per window - } -})); - -// Example of what happens with different requests: -console.log('šŸ”’ Strict RFC 8707 Mode Example\n'); -console.log(`This server validates that resource parameter matches: ${SERVER_URL}\n`); - -console.log('āœ… Valid request example:'); -console.log(`GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=${SERVER_URL}\n`); - -console.log('āŒ Invalid request examples:'); -console.log('1. Missing resource:'); -console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256'); -console.log('Response: 400 Bad Request - "Resource parameter is required when server URL validation is enabled"\n'); - -console.log('2. Wrong resource:'); -console.log('GET /oauth/authorize?client_id=my-client&response_type=code&code_challenge=abc123&code_challenge_method=S256&resource=https://evil.com/mcp'); -console.log(`Response: 400 Bad Request - "Resource parameter 'https://evil.com/mcp' does not match this server's URL '${SERVER_URL}'"\n`); - -console.log('šŸ“‹ Benefits of server URL validation:'); -console.log('1. Prevents token confusion attacks - tokens can only be issued for this server'); -console.log('2. Ensures all tokens are properly scoped to this specific MCP server'); -console.log('3. No accidental token leakage to other services'); -console.log('4. Clear security boundary enforcement\n'); - -console.log('āš ļø Migration considerations:'); -console.log('1. Server must know its canonical URL (configure via environment variable)'); -console.log('2. All clients must send the exact matching resource parameter'); -console.log('3. Consider using warnings-only mode first (validateResourceMatchesServer: false)'); -console.log('4. Monitor logs to track adoption before enabling validation\n'); - -// Example middleware to track resource parameter usage -app.use((req, res, next) => { - if (req.path.includes('/oauth/')) { - const hasResource = req.query.resource || req.body?.resource; - console.log(`[${new Date().toISOString()}] OAuth request to ${req.path} - Resource parameter: ${hasResource ? 'present' : 'MISSING'}`); - } - next(); -}); - -export { app, provider, strictConfig }; \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index dbed1b52..946c46c9 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -14,6 +14,7 @@ import { OAuthError } from "../errors.js"; import { OAuthServerConfig } from "../types.js"; +import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -137,7 +138,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig, con } // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 37950502..7af42d7a 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -16,6 +16,7 @@ import { InvalidTargetError } from "../errors.js"; import { OAuthServerConfig } from "../types.js"; +import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -110,7 +111,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); } - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( @@ -161,7 +162,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); } - const canonicalServerUrl = config.serverUrl!.split('#')[0]; + const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); if (resource !== canonicalServerUrl) { throw new InvalidTargetError( diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index 1c45511a..b9571408 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,4 +1,4 @@ -import { canonicalizeResourceUri, validateResourceUri, extractCanonicalResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; +import { validateResourceUri, extractResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -28,17 +28,6 @@ describe('auth-utils', () => { }); }); - describe('canonicalizeResourceUri', () => { - it('should remove fragments', () => { - expect(canonicalizeResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - }); - - it('should keep everything else unchanged', () => { - expect(canonicalizeResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); - expect(canonicalizeResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(canonicalizeResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); - }); - }); describe('validateResourceUri', () => { it('should accept valid resource URIs without fragments', () => { @@ -64,34 +53,34 @@ describe('auth-utils', () => { }); }); - describe('extractCanonicalResourceUri', () => { + describe('extractResourceUri', () => { it('should remove fragments from URLs', () => { - expect(extractCanonicalResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(extractCanonicalResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + expect(extractResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); + expect(extractResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); }); it('should handle URL object', () => { const url = new URL('https://example.com:8443/path?query=1#fragment'); - expect(extractCanonicalResourceUri(url)).toBe('https://example.com:8443/path?query=1'); + expect(extractResourceUri(url)).toBe('https://example.com:8443/path?query=1'); }); it('should keep everything else unchanged', () => { // Preserves case - expect(extractCanonicalResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); + expect(extractResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); // Preserves all ports - expect(extractCanonicalResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(extractCanonicalResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); + expect(extractResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); + expect(extractResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); // Preserves query parameters - expect(extractCanonicalResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + expect(extractResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); // Preserves trailing slashes - expect(extractCanonicalResourceUri('https://example.com/')).toBe('https://example.com/'); - expect(extractCanonicalResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); + expect(extractResourceUri('https://example.com/')).toBe('https://example.com/'); + expect(extractResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); }); it('should distinguish between different paths on same domain', () => { // This is the key test for the security concern mentioned - const app1 = extractCanonicalResourceUri('https://api.example.com/mcp-server-1'); - const app2 = extractCanonicalResourceUri('https://api.example.com/mcp-server-2'); + const app1 = extractResourceUri('https://api.example.com/mcp-server-1'); + const app2 = extractResourceUri('https://api.example.com/mcp-server-2'); expect(app1).not.toBe(app2); expect(app1).toBe('https://api.example.com/mcp-server-1'); expect(app2).toBe('https://api.example.com/mcp-server-2'); diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index aed5f247..e69d821d 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -23,14 +23,6 @@ export function validateResourceUri(resourceUri: string): void { } } -/** - * Removes fragment from URI to make it RFC 8707 compliant. - * @deprecated Use resourceUrlFromServerUrl instead - */ -export function canonicalizeResourceUri(resourceUri: string): string { - return resourceUrlFromServerUrl(resourceUri); -} - /** * Extracts resource URI from server URL by removing fragment. * @param serverUrl The server URL to extract from @@ -38,7 +30,4 @@ export function canonicalizeResourceUri(resourceUri: string): string { */ export function extractResourceUri(serverUrl: string | URL): string { return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); -} - -// Backward compatibility alias -export const extractCanonicalResourceUri = extractResourceUri; \ No newline at end of file +} \ No newline at end of file From cba6a6ea589e8e5276fe1aef5c4efa17e99adafa Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 20:41:56 +0100 Subject: [PATCH 03/41] Update authorize.config.test.ts Co-Authored-By: Claude --- src/server/auth/handlers/authorize.config.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts index aa180c4b..f0736da2 100644 --- a/src/server/auth/handlers/authorize.config.test.ts +++ b/src/server/auth/handlers/authorize.config.test.ts @@ -3,7 +3,6 @@ import request from "supertest"; import { authorizationHandler } from "./authorize.js"; import { OAuthServerProvider } from "../provider.js"; import { OAuthServerConfig } from "../types.js"; -import { InvalidRequestError, InvalidTargetError } from "../errors.js"; describe("Authorization handler with config", () => { let app: express.Application; From ccccb4b7ccb98b06b3537bf9eebc29bfc3c00368 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 21:34:06 +0100 Subject: [PATCH 04/41] simplify PR / only keep verification in demo inmemory oauth provider Co-Authored-By: Claude --- .../server/demoInMemoryOAuthProvider.test.ts | 175 ++++++++- .../server/demoInMemoryOAuthProvider.ts | 107 +++++- src/examples/server/simpleStreamableHttp.ts | 7 +- .../auth/handlers/authorize.config.test.ts | 360 ------------------ src/server/auth/handlers/authorize.ts | 36 +- src/server/auth/handlers/token.ts | 58 +-- src/server/auth/types.ts | 33 -- 7 files changed, 290 insertions(+), 486 deletions(-) delete mode 100644 src/server/auth/handlers/authorize.config.test.ts diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 49c6f69b..852f0c98 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; -import { InvalidTargetError } from '../../server/auth/errors.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore, DemoOAuthProviderConfig } from './demoInMemoryOAuthProvider.js'; +import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; import { OAuthClientInformationFull } from '../../shared/auth.js'; import { Response } from 'express'; @@ -215,4 +215,175 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); }); }); + + describe('Server URL validation configuration', () => { + it('should throw error when validateResourceMatchesServer is true but serverUrl is not set', () => { + const invalidConfig: DemoOAuthProviderConfig = { + validateResourceMatchesServer: true + // serverUrl is missing + }; + + expect(() => { + new DemoInMemoryAuthProvider(invalidConfig); + }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + }); + + describe('with server URL validation enabled', () => { + let strictProvider: DemoInMemoryAuthProvider; + + beforeEach(() => { + const config: DemoOAuthProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true + }; + strictProvider = new DemoInMemoryAuthProvider(config); + + strictProvider.clientsStore.registerClient(mockClient); + }); + + it('should reject authorization without resource parameter', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + // resource is missing + }, mockResponse as Response)).rejects.toThrow(InvalidRequestError); + }); + + it('should reject authorization with non-matching resource', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://different.api.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }); + + it('should accept authorization with matching resource', async () => { + await expect(strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + + expect(mockResponse.redirect).toHaveBeenCalled(); + }); + + it('should handle server URL with fragment correctly', async () => { + const configWithFragment: DemoOAuthProviderConfig = { + serverUrl: 'https://api.example.com/mcp#fragment', + validateResourceMatchesServer: true + }; + const providerWithFragment = new DemoInMemoryAuthProvider(configWithFragment); + + await providerWithFragment.clientsStore.registerClient(mockClient); + + // Should accept resource without fragment + await expect(providerWithFragment.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response)).resolves.not.toThrow(); + }); + + it('should reject token exchange without resource parameter', async () => { + // First authorize with resource + mockResponse.redirect = jest.fn(); + await strictProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const authCode = url.searchParams.get('code')!; + + await expect(strictProvider.exchangeAuthorizationCode( + mockClient, + authCode, + undefined, + undefined + // resource is missing + )).rejects.toThrow(InvalidRequestError); + }); + + it('should reject refresh token without resource parameter', async () => { + await expect(strictProvider.exchangeRefreshToken( + mockClient, + 'refresh-token', + undefined + // resource is missing + )).rejects.toThrow(InvalidRequestError); + }); + }); + + describe('with server URL validation disabled (warning mode)', () => { + let warnProvider: DemoInMemoryAuthProvider; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + warnProvider = new DemoInMemoryAuthProvider(); // No config = warnings enabled + + warnProvider.clientsStore.registerClient(mockClient); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + it('should log warning when resource is missing from authorization', async () => { + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + // resource is missing + }, mockResponse as Response); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('test-client is missing the resource parameter') + ); + }); + + it('should not log warning when resource is present', async () => { + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + resource: 'https://api.example.com/mcp', + scopes: ['mcp:tools'] + }, mockResponse as Response); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('should log warning when resource is missing from token exchange', async () => { + // First authorize without resource + await warnProvider.authorize(mockClient, { + codeChallenge: 'test-challenge', + redirectUri: mockClient.redirect_uris[0], + scopes: ['mcp:tools'] + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const authCode = url.searchParams.get('code')!; + + await warnProvider.exchangeAuthorizationCode( + mockClient, + authCode, + undefined, + undefined + // resource is missing + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('test-client is missing the resource parameter') + ); + }); + }); + }); }); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 66583e49..2f0e3539 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -5,7 +5,8 @@ import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../sh import express, { Request, Response } from "express"; import { AuthInfo } from '../../server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { InvalidTargetError } from '../../server/auth/errors.js'; +import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; +import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; interface ExtendedClientInformation extends OAuthClientInformationFull { @@ -48,19 +49,79 @@ interface ExtendedAuthInfo extends AuthInfo { type?: string; } +/** + * Configuration options for the demo OAuth provider + */ +export interface DemoOAuthProviderConfig { + /** + * The canonical URL of this MCP server. When provided, the provider will validate + * that the resource parameter in OAuth requests matches this URL. + * + * This should be the full URL that clients use to connect to this server, + * without any fragment component (e.g., "https://api.example.com/mcp"). + * + * Required when validateResourceMatchesServer is true. + */ + serverUrl?: string; + + /** + * If true, validates that the resource parameter matches the configured serverUrl. + * + * When enabled: + * - serverUrl must be configured (throws error if not) + * - resource parameter is required on all requests + * - resource must exactly match serverUrl (after fragment removal) + * - requests without resource parameter will be rejected with invalid_request error + * - requests with non-matching resource will be rejected with invalid_target error + * + * When disabled: + * - warnings are logged when resource parameter is missing (for migration tracking) + * + * @default false + */ + validateResourceMatchesServer?: boolean; +} + export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); private tokens = new Map(); + private config?: DemoOAuthProviderConfig; + + constructor(config?: DemoOAuthProviderConfig) { + if (config?.validateResourceMatchesServer && !config?.serverUrl) { + throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + } + this.config = config; + } async authorize( client: OAuthClientInformationFull, params: AuthorizationParams, res: Response ): Promise { - // Validate resource parameter if provided + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!params.resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + // Remove fragment from server URL if present (though it shouldn't have one) + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (params.resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${params.resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!params.resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Authorization request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + + // Additional validation: check if client is allowed to access the resource if (params.resource) { await this.validateResource(client, params.resource); } @@ -116,6 +177,24 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + // Validate that the resource matches what was authorized if (resource !== codeData.params.resource) { throw new InvalidTargetError('Resource parameter does not match the authorized resource'); @@ -154,7 +233,25 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _scopes?: string[], resource?: string ): Promise { - // Validate resource parameter if provided + // Validate resource parameter based on configuration + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); + } + + // Additional validation: check if client is allowed to access the resource if (resource) { await this.validateResource(client, resource); } @@ -204,13 +301,13 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = (authServerUrl: URL): OAuthMetadata => { +export const setupAuthServer = (authServerUrl: URL, config?: DemoOAuthProviderConfig): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(); + const provider = new DemoInMemoryAuthProvider(config); const authApp = express(); authApp.use(express.json()); // For introspection requests diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index ebe31920..65b6263e 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -282,7 +282,12 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl); + // Configure the demo auth provider to validate resources match this server + const demoProviderConfig = { + serverUrl: mcpServerUrl.href, + validateResourceMatchesServer: false // Set to true to enable strict validation + }; + const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, demoProviderConfig); const tokenVerifier = { verifyAccessToken: async (token: string) => { diff --git a/src/server/auth/handlers/authorize.config.test.ts b/src/server/auth/handlers/authorize.config.test.ts deleted file mode 100644 index f0736da2..00000000 --- a/src/server/auth/handlers/authorize.config.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { authorizationHandler } from "./authorize.js"; -import { OAuthServerProvider } from "../provider.js"; -import { OAuthServerConfig } from "../types.js"; - -describe("Authorization handler with config", () => { - let app: express.Application; - let mockProvider: jest.Mocked; - - beforeEach(() => { - app = express(); - - const mockClientsStore = { - getClient: jest.fn(), - registerClient: jest.fn(), - }; - - mockProvider = { - clientsStore: mockClientsStore, - authorize: jest.fn(), - exchangeAuthorizationCode: jest.fn(), - exchangeRefreshToken: jest.fn(), - challengeForAuthorizationCode: jest.fn(), - verifyAccessToken: jest.fn(), - } as jest.Mocked; - }); - - describe("validateResourceMatchesServer configuration", () => { - it("should throw error when validateResourceMatchesServer is true but serverUrl is not set", () => { - const invalidConfig: OAuthServerConfig = { - validateResourceMatchesServer: true - // serverUrl is missing - }; - - expect(() => { - authorizationHandler({ - provider: mockProvider, - config: invalidConfig - }); - }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); - }); - }); - - describe("server URL validation (validateResourceMatchesServer: true)", () => { - const serverValidationConfig: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp", - validateResourceMatchesServer: true - }; - - beforeEach(() => { - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: serverValidationConfig - })); - }); - - it("should reject requests without resource parameter", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_request"); - expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); - }); - - it("should accept requests with resource parameter", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - expect(mockProvider.authorize).toHaveBeenCalledWith( - mockClient, - expect.objectContaining({ - resource: "https://api.example.com/mcp" - }), - expect.any(Object) - ); - }); - }); - - describe("warning mode (default behavior)", () => { - const warnConfig: OAuthServerConfig = { - // No configuration needed - warnings are always enabled by default - }; - - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: warnConfig - })); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - - it("should log warning when resource is missing", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining("test-client is missing the resource parameter") - ); - }); - - it("should not log warning when resource is present", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - }); - - // Note: No silent mode test anymore - warnings are always enabled - - describe("server URL validation (validateResourceMatchesServer: true)", () => { - const serverValidationConfig: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp", - validateResourceMatchesServer: true - }; - - beforeEach(() => { - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: serverValidationConfig - })); - }); - - it("should accept requests when resource matches server URL", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - }); - - it("should reject requests when resource does not match server URL", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://different.api.com/mcp" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_target"); - expect(response.headers.location).toContain("does+not+match+this+server"); - }); - - it("should reject requests without resource parameter when validation is enabled", async () => { - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state" - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("error=invalid_request"); - expect(response.headers.location).toContain("Resource+parameter+is+required+when+server+URL+validation+is+enabled"); - }); - - it("should handle server URL with fragment correctly", async () => { - // Reconfigure with a server URL that has a fragment (though it shouldn't) - const configWithFragment: OAuthServerConfig = { - serverUrl: "https://api.example.com/mcp#fragment", - validateResourceMatchesServer: true - }; - - app = express(); - app.use("/oauth/authorize", authorizationHandler({ - provider: mockProvider, - config: configWithFragment - })); - - const mockClient = { - client_id: "test-client", - client_name: "Test Client", - redirect_uris: ["https://example.com/callback"], - grant_types: ["authorization_code"], - response_types: ["code"], - scope: "read write", - token_endpoint_auth_method: "none", - }; - - (mockProvider.clientsStore.getClient as jest.Mock).mockResolvedValue(mockClient); - mockProvider.authorize.mockImplementation(async (client, params, res) => { - res.redirect(`https://example.com/callback?code=auth-code&state=${params.state}`); - }); - - const response = await request(app) - .get("/oauth/authorize") - .query({ - client_id: "test-client", - redirect_uri: "https://example.com/callback", - response_type: "code", - code_challenge: "test-challenge", - code_challenge_method: "S256", - state: "test-state", - resource: "https://api.example.com/mcp" // No fragment - }); - - expect(response.status).toBe(302); - expect(response.headers.location).toContain("code=auth-code"); - }); - }); -}); \ No newline at end of file diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 946c46c9..f6c862ac 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -8,13 +8,10 @@ import { InvalidRequestError, InvalidClientError, InvalidScopeError, - InvalidTargetError, ServerError, TooManyRequestsError, OAuthError } from "../errors.js"; -import { OAuthServerConfig } from "../types.js"; -import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type AuthorizationHandlerOptions = { provider: OAuthServerProvider; @@ -23,10 +20,6 @@ export type AuthorizationHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; - /** - * OAuth server configuration options - */ - config?: OAuthServerConfig; }; // Parameters that must be validated in order to issue redirects. @@ -45,12 +38,7 @@ const RequestAuthorizationParamsSchema = z.object({ resource: z.string().url().optional(), }); -export function authorizationHandler({ provider, rateLimit: rateLimitConfig, config }: AuthorizationHandlerOptions): RequestHandler { - // Validate configuration - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); - } - +export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: AuthorizationHandlerOptions): RequestHandler { // Create a router to apply middleware const router = express.Router(); router.use(allowedMethods(["GET", "POST"])); @@ -131,26 +119,8 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig, con const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Authorization request from client ${client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it // Validate scopes let requestedScopes: string[] = []; diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 7af42d7a..92fe9921 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -12,11 +12,8 @@ import { UnsupportedGrantTypeError, ServerError, TooManyRequestsError, - OAuthError, - InvalidTargetError + OAuthError } from "../errors.js"; -import { OAuthServerConfig } from "../types.js"; -import { resourceUrlFromServerUrl } from "../../../shared/auth-utils.js"; export type TokenHandlerOptions = { provider: OAuthServerProvider; @@ -25,10 +22,6 @@ export type TokenHandlerOptions = { * Set to false to disable rate limiting for this endpoint. */ rateLimit?: Partial | false; - /** - * OAuth server configuration options - */ - config?: OAuthServerConfig; }; const TokenRequestSchema = z.object({ @@ -48,12 +41,7 @@ const RefreshTokenGrantSchema = z.object({ resource: z.string().url().optional(), }); -export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: TokenHandlerOptions): RequestHandler { - // Validate configuration - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); - } - +export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -105,25 +93,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T const { code, code_verifier, redirect_uri, resource } = parseResult.data; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it const skipLocalPkceValidation = provider.skipLocalPkceValidation; @@ -156,25 +127,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig, config }: T const { refresh_token, scope, resource } = parseResult.data; - // If validateResourceMatchesServer is enabled, resource is required and must match - if (config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else { - // Always log warning if resource is missing (unless validation is enabled) - if (!resource) { - console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } + // Pass through the resource parameter to the provider + // The provider can decide how to validate it const scopes = scope?.split(" "); const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index 33ba3f86..c25c2b60 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -27,37 +27,4 @@ export interface AuthInfo { * This field should be used for any additional data that needs to be attached to the auth info. */ extra?: Record; -} - -/** - * Configuration options for OAuth server behavior - */ -export interface OAuthServerConfig { - /** - * The canonical URL of this MCP server. When provided, the server will validate - * that the resource parameter in OAuth requests matches this URL. - * - * This should be the full URL that clients use to connect to this server, - * without any fragment component (e.g., "https://api.example.com/mcp"). - * - * Required when validateResourceMatchesServer is true. - */ - serverUrl?: string; - - /** - * If true, validates that the resource parameter matches the configured serverUrl. - * - * When enabled: - * - serverUrl must be configured (throws error if not) - * - resource parameter is required on all requests - * - resource must exactly match serverUrl (after fragment removal) - * - requests without resource parameter will be rejected with invalid_request error - * - requests with non-matching resource will be rejected with invalid_target error - * - * When disabled: - * - warnings are logged when resource parameter is missing (for migration tracking) - * - * @default false - */ - validateResourceMatchesServer?: boolean; } \ No newline at end of file From e542ec1989636987867ea082adb0e0b8eab88c91 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 21:38:00 +0100 Subject: [PATCH 05/41] docs: update PR description to clarify server-side validation is in demo provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify that core server handlers only pass through resource parameter - Emphasize that server URL validation is demonstrated in the demo provider - Update issue references to show #592 is fixed, #635 is related - Update examples to show DemoOAuthProviderConfig usage šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.local.md | 125 ++++++++++++++++++++++++++++++++++++++++++++++ PR-DESCRIPTION.md | 111 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 CLAUDE.local.md create mode 100644 PR-DESCRIPTION.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md new file mode 100644 index 00000000..9a43ac7c --- /dev/null +++ b/CLAUDE.local.md @@ -0,0 +1,125 @@ +# RFC 8707 Resource Indicators Implementation for MCP TypeScript SDK + +This PR implements RFC 8707 (Resource Indicators for OAuth 2.0) in the MCP TypeScript SDK, addressing critical security vulnerabilities and adding resource-scoped authorization support. + +## Issues Addressed + +- **Fixes #592**: Implements client-side resource parameter passing to prevent token confusion attacks +- **Related to #635**: Demonstrates server-side RFC 8707 validation in the demo OAuth provider + +## Overview + +This implementation adds resource parameter support to MCP's OAuth flow, explicitly binding access tokens to specific MCP servers. This prevents malicious servers from stealing OAuth tokens intended for other services. + +## Implementation Summary + +### 1. Core Auth Infrastructure + +#### Client-Side Changes (`src/client/`) +- **auth.ts**: Added resource parameter support to authorization and token exchange flows +- **Transport layers** (sse.ts, streamableHttp.ts): Automatically extract canonical server URIs for resource parameter + +#### Server-Side Changes (`src/server/auth/`) +- **handlers/**: Updated authorize and token handlers to accept and pass through resource parameters +- **provider.ts**: Extended provider interface to support resource parameters +- **errors.ts**: Added `InvalidTargetError` for RFC 8707 compliance + +#### Shared Utilities (`src/shared/`) +- **auth-utils.ts**: Created utilities for resource URI validation and canonicalization +- **auth.ts**: Updated OAuth schemas to include resource parameter + +### 2. Demo OAuth Provider Enhancement (`src/examples/server/`) + +The demo provider demonstrates how to implement RFC 8707 validation: +- Optional resource validation during authorization (via `DemoOAuthProviderConfig`) +- Resource consistency checks during token exchange +- Resource information included in token introspection +- Support for validating resources against a configured server URL +- Client-specific resource allowlists + +### 3. Resource URI Requirements + +Resource URIs follow RFC 8707 requirements: +- **MUST NOT** include fragments (automatically removed by the SDK) +- The SDK preserves all other URL components (scheme, host, port, path, query) exactly as provided +- No additional canonicalization is performed to maintain compatibility with various server configurations + +## Client vs Server Implementation Differences + +### Client-Side Implementation +- **Automatic resource extraction**: Transports automatically determine the server URI for resource parameter +- **Transparent integration**: Resource parameter is added without changing existing auth APIs +- **Fragment removal**: Fragments are automatically removed from URIs per RFC 8707 +- **Focus**: Ensuring resource parameter is correctly included in all OAuth requests + +### Server-Side Implementation +- **Core handlers**: Pass through resource parameter without validation +- **Demo provider**: Shows how to implement resource validation +- **Provider flexibility**: Auth providers decide how to enforce resource restrictions +- **Backward compatibility**: Servers work with clients that don't send resource parameter +- **Focus**: Demonstrating best practices for resource validation + +## Testing Approach Differences + +### Client-Side Tests +- **Unit tests**: Verify resource parameter is included in auth URLs and token requests +- **Validation tests**: Ensure resource URI validation and canonicalization work correctly +- **Integration focus**: Test interaction between transport layer and auth module + +### Server-Side Tests +- **Handler tests**: Verify resource parameter is accepted and passed to providers +- **Demo provider tests**: Comprehensive tests for server URL validation and client-specific allowlists +- **Security tests**: Verify invalid resources are rejected with proper errors +- **Configuration tests**: Test various demo provider configurations +- **End-to-end tests**: Full OAuth flow with resource validation + +## Security Considerations + +1. **Token Binding**: Tokens are explicitly bound to the resource they're intended for +2. **Validation**: Both client and server validate resource URIs to prevent attacks +3. **Consistency**: Resource must match between authorization and token exchange +4. **Introspection**: Resource information is included in token introspection responses + +## Migration Guide + +### For Client Developers +No changes required - the SDK automatically includes the resource parameter based on the server URL. + +### For Server Developers +1. Core server handlers automatically pass through the resource parameter +2. Custom auth providers can implement resource validation as shown in the demo provider +3. Demo provider configuration options: + - `serverUrl`: The canonical URL of the MCP server + - `validateResourceMatchesServer`: Enable strict resource validation +4. Return `invalid_target` error for unauthorized resources +5. Include resource in token introspection responses + +## Example Usage + +```typescript +// Client automatically includes resource parameter +const transport = new StreamableHttpClientTransport( + 'https://api.example.com/mcp', + authProvider +); + +// Demo provider configuration with resource validation +const demoProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true // Makes resource required and validates it +}; +const provider = new DemoInMemoryAuthProvider(demoProviderConfig); +``` + +## Future Enhancements + +1. Add support for multiple resource parameters (RFC 8707 allows arrays) +2. Implement resource-specific scope restrictions +3. Add telemetry for resource parameter usage +4. Create migration tooling for existing deployments + +## References + +- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) +- [MCP Issue #544 - Security Vulnerability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544) \ No newline at end of file diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md new file mode 100644 index 00000000..b4e48cbd --- /dev/null +++ b/PR-DESCRIPTION.md @@ -0,0 +1,111 @@ +# RFC 8707 Resource Indicators Implementation + + +Implements RFC 8707 (Resource Indicators for OAuth 2.0) support in the MCP TypeScript SDK. This adds the `resource` parameter to OAuth authorization and token exchange flows, allowing access tokens to be explicitly bound to specific MCP servers. The implementation includes automatic resource extraction in client transports, server-side parameter passing, and demonstrates resource validation in the demo OAuth provider. + +(Fixes #592, Related to #635) + +## Motivation and Context + +This change addresses critical security vulnerabilities identified in https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544. Without resource indicators, OAuth tokens intended for one MCP server could be stolen and misused by malicious servers. RFC 8707 prevents these token confusion attacks by explicitly binding tokens to their intended resources. + +Key problems solved: +- Prevents token theft/confusion attacks where a malicious MCP server steals tokens meant for other services +- Enables fine-grained access control by restricting OAuth clients to specific resources +- Improves security posture by following OAuth 2.0 Security Best Current Practice recommendations + +## How Has This Been Tested? + +Comprehensive test coverage has been added: + +**Client-side testing:** +- Unit tests verify resource parameter inclusion in authorization URLs and token requests (512 new lines in auth.test.ts) +- Transport layer tests ensure automatic resource extraction works correctly +- Fragment removal and URI validation tests + +**Server-side testing:** +- Authorization handler tests for resource parameter acceptance +- Token handler tests for resource parameter passing +- Demo provider tests for resource restrictions and validation (including server URL validation) +- Proxy provider tests for resource parameter forwarding + +**Integration testing:** +- End-to-end OAuth flow with resource validation +- Resource validation example demonstrating real-world usage patterns +- Tests for both clients with and without resource restrictions + +## Breaking Changes + +While the change is breaking at a protocol level, it should not require code changes from SDK users (just SDK version bumping). + +- **Client developers**: No code changes required. The SDK automatically extracts and includes the resource parameter from the server URL +- **Server developers**: The core server handlers now pass through the resource parameter. Resource validation is demonstrated in the demo provider but remains optional for custom providers +- **Auth providers**: Should be updated to accept and handle the resource parameter. The demo provider shows how to implement server URL validation and client-specific resource restrictions + +## Types of changes + +- [x] Bug fix (non-breaking change which fixes an issue) +- [x] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) +- [ ] Documentation update + +## Checklist + +- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) +- [x] My code follows the repository's style guidelines +- [x] New and existing tests pass locally +- [x] I have added appropriate error handling +- [x] I have added or updated documentation as needed + +## Additional context + + +### Server-Side Implementation Approach + +The core server implementation focuses on passing through the resource parameter without enforcing validation, maintaining backward compatibility and flexibility. The demo provider demonstrates how to implement RFC 8707 validation: + +1. **Core Server**: Handlers accept and forward the resource parameter to auth providers without validation +2. **Demo Provider**: Shows how to implement comprehensive resource validation including: + - Server URL matching validation (configurable via `DemoOAuthProviderConfig`) + - Client-specific resource allowlists + - Warning logs for missing resource parameters + - Consistent resource validation between authorization and token exchange + +This separation allows: +- Existing providers to continue working without modification +- New providers to implement validation according to their security requirements +- Gradual migration to RFC 8707 compliance +- Different validation strategies for different deployment scenarios + +### Implementation Approach + +Resource URIs are used as-is with only fragment removal (per RFC requirement). This allows having different MCP servers under different subpaths (even w/ different query URLs) w/o sharing spilling their resource authorization to each other (to allow a variety of MCP server federation use cases). + +### Key Components Added +1. **Shared utilities** (`auth-utils.ts`): Resource URI handling and validation +2. **Client auth** modifications: Resource parameter support in authorization/token flows +3. **Transport layers**: Automatic resource extraction from server URLs +4. **Server handlers**: Resource parameter acceptance and forwarding +5. **Demo provider**: Full RFC 8707 implementation with resource validation +6. **Error handling**: New `InvalidTargetError` for RFC 8707 compliance + +### Example Usage +```typescript +// Client-side (automatic) +const transport = new StreamableHttpClientTransport( + 'https://api.example.com/mcp', + authProvider +); + +// Demo provider configuration with validation +const demoProviderConfig = { + serverUrl: 'https://api.example.com/mcp', + validateResourceMatchesServer: true // Makes resource required and validates it matches serverUrl +}; +const provider = new DemoInMemoryAuthProvider(demoProviderConfig); +``` + +### References +- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) +- Fixes #592: OAuth token confusion vulnerability - client-side resource parameter support +- Related to #635: Demonstrates server-side RFC 8707 validation in demo provider \ No newline at end of file From 6656d23d84cd9058dc8f8cfdd23a1a343a0d1a92 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 22:56:55 +0100 Subject: [PATCH 06/41] Simplify demo in-memory oauth provider Co-Authored-By: Claude --- .../server/demoInMemoryOAuthProvider.ts | 148 ++++-------------- src/server/auth/types.ts | 6 + 2 files changed, 33 insertions(+), 121 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 2f0e3539..3672c3e0 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -9,44 +9,17 @@ import { InvalidTargetError, InvalidRequestError } from '../../server/auth/error import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; -interface ExtendedClientInformation extends OAuthClientInformationFull { - allowed_resources?: string[]; -} - export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { - private clients = new Map(); + private clients = new Map(); async getClient(clientId: string) { return this.clients.get(clientId); } - async registerClient(clientMetadata: OAuthClientInformationFull & { allowed_resources?: string[] }) { + async registerClient(clientMetadata: OAuthClientInformationFull) { this.clients.set(clientMetadata.client_id, clientMetadata); return clientMetadata; } - - /** - * Demo method to set allowed resources for a client - */ - setAllowedResources(clientId: string, resources: string[]) { - const client = this.clients.get(clientId); - if (client) { - client.allowed_resources = resources; - } - } -} - -/** - * 🚨 DEMO ONLY - NOT FOR PRODUCTION - * - * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, - * for example: - * - Persistent token storage - * - Rate limiting - */ -interface ExtendedAuthInfo extends AuthInfo { - resource?: string; - type?: string; } /** @@ -87,7 +60,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { private codes = new Map(); - private tokens = new Map(); + private tokens = new Map(); private config?: DemoOAuthProviderConfig; constructor(config?: DemoOAuthProviderConfig) { @@ -102,29 +75,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response ): Promise { - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!params.resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - // Remove fragment from server URL if present (though it shouldn't have one) - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (params.resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${params.resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!params.resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Authorization request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Additional validation: check if client is allowed to access the resource - if (params.resource) { - await this.validateResource(client, params.resource); - } + await this.validateResource(params.resource); const code = randomUUID(); @@ -164,9 +115,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { authorizationCode: string, // Note: code verifier is checked in token.ts by default // it's unused here for that reason. - _codeVerifier?: string, - _redirectUri?: string, - resource?: string + _codeVerifier?: string ): Promise { const codeData = this.codes.get(authorizationCode); if (!codeData) { @@ -177,44 +126,18 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token exchange request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Validate that the resource matches what was authorized - if (resource !== codeData.params.resource) { - throw new InvalidTargetError('Resource parameter does not match the authorized resource'); - } - - // If resource was specified during authorization, validate it's still allowed - if (codeData.params.resource) { - await this.validateResource(client, codeData.params.resource); - } + await this.validateResource(codeData.params.resource); this.codes.delete(authorizationCode); const token = randomUUID(); - const tokenData: ExtendedAuthInfo = { + const tokenData = { token, clientId: client.client_id, scopes: codeData.params.scopes || [], expiresAt: Date.now() + 3600000, // 1 hour + resource: codeData.params.resource, type: 'access', - resource: codeData.params.resource }; this.tokens.set(token, tokenData); @@ -233,28 +156,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _scopes?: string[], resource?: string ): Promise { - // Validate resource parameter based on configuration - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token refresh request from client ${client.client_id} is missing the resource parameter. Consider migrating to RFC 8707.`); - } - - // Additional validation: check if client is allowed to access the resource - if (resource) { - await this.validateResource(client, resource); - } throw new Error('Refresh tokens not implemented for example demo'); } @@ -263,12 +164,14 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { throw new Error('Invalid or expired token'); } + await this.validateResource(tokenData.resource); return { token, clientId: tokenData.clientId, scopes: tokenData.scopes, expiresAt: Math.floor(tokenData.expiresAt / 1000), + resource: tokenData.resource, }; } @@ -276,26 +179,29 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { * Validates that the client is allowed to access the requested resource. * In a real implementation, this would check against a database or configuration. */ - private async validateResource(client: OAuthClientInformationFull, resource: string): Promise { - const extendedClient = client as ExtendedClientInformation; - - // If no resources are configured, allow any resource (for demo purposes) - if (!extendedClient.allowed_resources) { - return; - } - - // Check if the requested resource is in the allowed list - if (!extendedClient.allowed_resources.includes(resource)) { - throw new InvalidTargetError( - `Client is not authorized to access resource: ${resource}` - ); + private async validateResource(resource?: string): Promise { + if (this.config?.validateResourceMatchesServer) { + if (!resource) { + throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); + } + + const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); + + if (resource !== canonicalServerUrl) { + throw new InvalidTargetError( + `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` + ); + } + } else if (!resource) { + // Always log warning if resource is missing (unless validation is enabled) + console.warn(`Token refresh request is missing the resource parameter. Consider migrating to RFC 8707.`); } } /** * Get token details including resource information (for demo introspection endpoint) */ - getTokenDetails(token: string): ExtendedAuthInfo | undefined { + getTokenDetails(token: string): AuthInfo | undefined { return this.tokens.get(token); } } diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index c25c2b60..bf1a257b 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -22,6 +22,12 @@ export interface AuthInfo { */ expiresAt?: number; + /** + * The RFC 8707 resource server identifier for which this token is valid. + * If set, this MUST match the MCP server's resource identifier (minus hash fragment). + */ + resource?: string; + /** * Additional data associated with the token. * This field should be used for any additional data that needs to be attached to the auth info. From 02ce81b90c4ebe55e13b064051950f5fe2951daa Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 23:10:27 +0100 Subject: [PATCH 07/41] simplify diff --- .../server/demoInMemoryOAuthProvider.ts | 82 +++---------------- src/server/auth/errors.ts | 10 --- src/server/auth/provider.ts | 2 +- src/server/auth/types.ts | 2 +- src/shared/auth-utils.ts | 29 ++----- 5 files changed, 20 insertions(+), 105 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 3672c3e0..5de0fb90 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -5,7 +5,6 @@ import { OAuthClientInformationFull, OAuthMetadata, OAuthTokens } from '../../sh import express, { Request, Response } from "express"; import { AuthInfo } from '../../server/auth/types.js'; import { createOAuthMetadata, mcpAuthRouter } from '../../server/auth/router.js'; -import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; import { resourceUrlFromServerUrl } from '../../shared/auth-utils.js'; @@ -22,52 +21,21 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { } } -/** - * Configuration options for the demo OAuth provider - */ -export interface DemoOAuthProviderConfig { - /** - * The canonical URL of this MCP server. When provided, the provider will validate - * that the resource parameter in OAuth requests matches this URL. - * - * This should be the full URL that clients use to connect to this server, - * without any fragment component (e.g., "https://api.example.com/mcp"). - * - * Required when validateResourceMatchesServer is true. - */ - serverUrl?: string; - - /** - * If true, validates that the resource parameter matches the configured serverUrl. - * - * When enabled: - * - serverUrl must be configured (throws error if not) - * - resource parameter is required on all requests - * - resource must exactly match serverUrl (after fragment removal) - * - requests without resource parameter will be rejected with invalid_request error - * - requests with non-matching resource will be rejected with invalid_target error - * - * When disabled: - * - warnings are logged when resource parameter is missing (for migration tracking) - * - * @default false - */ - validateResourceMatchesServer?: boolean; -} - export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map(); private tokens = new Map(); - private config?: DemoOAuthProviderConfig; - - constructor(config?: DemoOAuthProviderConfig) { - if (config?.validateResourceMatchesServer && !config?.serverUrl) { - throw new Error("serverUrl must be configured when validateResourceMatchesServer is true"); + private validateResource?: (resource?: URL) => boolean; + + constructor(mcpServerUrl?: URL) { + if (mcpServerUrl) { + const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); + this.validateResource = (resource?: URL) => { + return !resource || resource.toString() !== expectedResource.toString(); + }; } - this.config = config; } async authorize( @@ -75,8 +43,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response ): Promise { - await this.validateResource(params.resource); - const code = randomUUID(); const searchParams = new URLSearchParams({ @@ -126,7 +92,9 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error(`Authorization code was not issued to this client, ${codeData.client.client_id} != ${client.client_id}`); } - await this.validateResource(codeData.params.resource); + if (this.validateResource && !this.validateResource(codeData.params.resource)) { + throw new Error('Invalid resource'); + } this.codes.delete(authorizationCode); const token = randomUUID(); @@ -164,7 +132,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { throw new Error('Invalid or expired token'); } - await this.validateResource(tokenData.resource); return { token, @@ -175,29 +142,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { }; } - /** - * Validates that the client is allowed to access the requested resource. - * In a real implementation, this would check against a database or configuration. - */ - private async validateResource(resource?: string): Promise { - if (this.config?.validateResourceMatchesServer) { - if (!resource) { - throw new InvalidRequestError("Resource parameter is required when server URL validation is enabled"); - } - - const canonicalServerUrl = resourceUrlFromServerUrl(this.config.serverUrl!); - - if (resource !== canonicalServerUrl) { - throw new InvalidTargetError( - `Resource parameter '${resource}' does not match this server's URL '${canonicalServerUrl}'` - ); - } - } else if (!resource) { - // Always log warning if resource is missing (unless validation is enabled) - console.warn(`Token refresh request is missing the resource parameter. Consider migrating to RFC 8707.`); - } - } - /** * Get token details including resource information (for demo introspection endpoint) */ @@ -207,13 +151,13 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } -export const setupAuthServer = (authServerUrl: URL, config?: DemoOAuthProviderConfig): OAuthMetadata => { +export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMetadata => { // Create separate auth server app // NOTE: This is a separate app on a separate port to illustrate // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(config); + const provider = new DemoInMemoryAuthProvider(mcpServerUrl); const authApp = express(); authApp.use(express.json()); // For introspection requests diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 5c001bcd..428199ce 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -189,13 +189,3 @@ export class InsufficientScopeError extends OAuthError { super("insufficient_scope", message, errorUri); } } - -/** - * Invalid target error - The requested resource is invalid, unknown, or malformed. - * (RFC 8707 - Resource Indicators for OAuth 2.0) - */ -export class InvalidTargetError extends OAuthError { - constructor(message: string, errorUri?: string) { - super("invalid_target", message, errorUri); - } -} diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 25698416..93a56a09 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,7 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; - resource?: string; + resource?: URL; }; /** diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index bf1a257b..0189e9ed 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -26,7 +26,7 @@ export interface AuthInfo { * The RFC 8707 resource server identifier for which this token is valid. * If set, this MUST match the MCP server's resource identifier (minus hash fragment). */ - resource?: string; + resource?: URL; /** * Additional data associated with the token. diff --git a/src/shared/auth-utils.ts b/src/shared/auth-utils.ts index e69d821d..086d812f 100644 --- a/src/shared/auth-utils.ts +++ b/src/shared/auth-utils.ts @@ -1,5 +1,5 @@ /** - * Utilities for handling OAuth resource URIs according to RFC 8707. + * Utilities for handling OAuth resource URIs. */ /** @@ -7,27 +7,8 @@ * RFC 8707 section 2 states that resource URIs "MUST NOT include a fragment component". * Keeps everything else unchanged (scheme, domain, port, path, query). */ -export function resourceUrlFromServerUrl(url: string): string { - const hashIndex = url.indexOf('#'); - return hashIndex === -1 ? url : url.substring(0, hashIndex); +export function resourceUrlFromServerUrl(url: URL): URL { + const resourceURL = new URL(url.href); + resourceURL.hash = ''; // Remove fragment + return resourceURL; } - -/** - * Validates a resource URI according to RFC 8707 requirements. - * @param resourceUri The resource URI to validate - * @throws Error if the URI contains a fragment - */ -export function validateResourceUri(resourceUri: string): void { - if (resourceUri.includes('#')) { - throw new Error(`Invalid resource URI: ${resourceUri} - must not contain a fragment`); - } -} - -/** - * Extracts resource URI from server URL by removing fragment. - * @param serverUrl The server URL to extract from - * @returns The resource URI without fragment - */ -export function extractResourceUri(serverUrl: string | URL): string { - return resourceUrlFromServerUrl(typeof serverUrl === 'string' ? serverUrl : serverUrl.href); -} \ No newline at end of file From 36f338ae23503ce837f12c0f166e09de50885d16 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Mon, 16 Jun 2025 23:39:51 +0100 Subject: [PATCH 08/41] Update demoInMemoryOAuthProvider.ts --- src/examples/server/demoInMemoryOAuthProvider.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 5de0fb90..9fcb2517 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -29,7 +29,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { private tokens = new Map(); private validateResource?: (resource?: URL) => boolean; - constructor(mcpServerUrl?: URL) { + constructor({mcpServerUrl}: {mcpServerUrl?: URL} = {}) { if (mcpServerUrl) { const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); this.validateResource = (resource?: URL) => { @@ -157,7 +157,7 @@ export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMet // how to separate an OAuth Authorization Server from a Resource // server in the SDK. The SDK is not intended to be provide a standalone // authorization server. - const provider = new DemoInMemoryAuthProvider(mcpServerUrl); + const provider = new DemoInMemoryAuthProvider({mcpServerUrl}); const authApp = express(); authApp.use(express.json()); // For introspection requests From 224a2e242d956f30b20fdeb370c8b9958e321f0c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:18:33 +0100 Subject: [PATCH 09/41] update resource to be a url --- src/client/auth.ts | 18 ++-- .../server/demoInMemoryOAuthProvider.ts | 2 +- src/server/auth/handlers/authorize.test.ts | 6 +- src/server/auth/handlers/authorize.ts | 2 +- src/server/auth/provider.ts | 4 +- .../auth/providers/proxyProvider.test.ts | 2 +- src/server/auth/providers/proxyProvider.ts | 6 +- src/shared/auth-utils.test.ts | 85 +++---------------- 8 files changed, 33 insertions(+), 92 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 28188b7c..e465ea3b 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -100,12 +100,12 @@ export async function auth( authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL; - resource?: string }): Promise { + resource?: URL }): Promise { // Remove fragment from resource parameter if provided - let canonicalResource: string | undefined; + let canonicalResource: URL | undefined; if (resource) { - canonicalResource = resourceUrlFromServerUrl(resource); + canonicalResource = resourceUrlFromServerUrl(new URL(resource)); } let authorizationServerUrl = serverUrl; @@ -329,7 +329,7 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; - resource?: string; + resource?: URL; }, ): Promise<{ authorizationUrl: URL; codeVerifier: string }> { const responseType = "code"; @@ -380,7 +380,7 @@ export async function startAuthorization( } if (resource) { - authorizationUrl.searchParams.set("resource", resource); + authorizationUrl.searchParams.set("resource", resource.href); } return { authorizationUrl, codeVerifier }; @@ -404,7 +404,7 @@ export async function exchangeAuthorization( authorizationCode: string; codeVerifier: string; redirectUri: string | URL; - resource?: string; + resource?: URL; }, ): Promise { const grantType = "authorization_code"; @@ -439,7 +439,7 @@ export async function exchangeAuthorization( } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(tokenUrl, { @@ -471,7 +471,7 @@ export async function refreshAuthorization( metadata?: OAuthMetadata; clientInformation: OAuthClientInformation; refreshToken: string; - resource?: string; + resource?: URL; }, ): Promise { const grantType = "refresh_token"; @@ -504,7 +504,7 @@ export async function refreshAuthorization( } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(tokenUrl, { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 9fcb2517..316d1f8a 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -122,7 +122,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { client: OAuthClientInformationFull, _refreshToken: string, _scopes?: string[], - resource?: string + resource?: URL ): Promise { throw new Error('Refresh tokens not implemented for example demo'); } diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 20a2af89..2742d1e5 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -295,7 +295,7 @@ describe('Authorization Handler', () => { expect(mockProviderWithResource).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api.example.com/resource', + resource: new URL('https://api.example.com/resource'), redirectUri: 'https://example.com/callback', codeChallenge: 'challenge123' }), @@ -365,7 +365,7 @@ describe('Authorization Handler', () => { expect(mockProviderWithResources).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api1.example.com/resource', + resource: new URL('https://api1.example.com/resource'), state: 'test-state' }), expect.any(Object) @@ -391,7 +391,7 @@ describe('Authorization Handler', () => { expect(mockProviderPost).toHaveBeenCalledWith( validClient, expect.objectContaining({ - resource: 'https://api.example.com/resource' + resource: new URL('https://api.example.com/resource') }), expect.any(Object) ); diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index f6c862ac..17c88b45 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -142,7 +142,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, - resource, + resource: resource ? new URL(resource) : undefined, }, res); } catch (error) { // Post-redirect errors - redirect with error parameters diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 93a56a09..18beb216 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -42,13 +42,13 @@ export interface OAuthServerProvider { authorizationCode: string, codeVerifier?: string, redirectUri?: string, - resource?: string + resource?: URL ): Promise; /** * Exchanges a refresh token for an access token. */ - exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: string): Promise; + exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise; /** * Verifies an access token and returns information about it. diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index b652390b..75dc1a15 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -112,7 +112,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: 'test-challenge', state: 'test-state', scopes: ['read', 'write'], - resource: 'https://api.example.com/resource' + resource: new URL('https://api.example.com/resource') }, mockResponse ); diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 7f8b8d3d..4c807444 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -134,7 +134,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { // Add optional standard OAuth parameters if (params.state) searchParams.set("state", params.state); if (params.scopes?.length) searchParams.set("scope", params.scopes.join(" ")); - if (params.resource) searchParams.set("resource", params.resource); + if (params.resource) searchParams.set("resource", params.resource.href); targetUrl.search = searchParams.toString(); res.redirect(targetUrl.toString()); @@ -154,7 +154,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { authorizationCode: string, codeVerifier?: string, redirectUri?: string, - resource?: string + resource?: URL ): Promise { const params = new URLSearchParams({ grant_type: "authorization_code", @@ -199,7 +199,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], - resource?: string + resource?: URL ): Promise { const params = new URLSearchParams({ diff --git a/src/shared/auth-utils.test.ts b/src/shared/auth-utils.test.ts index b9571408..c35bb122 100644 --- a/src/shared/auth-utils.test.ts +++ b/src/shared/auth-utils.test.ts @@ -1,89 +1,30 @@ -import { validateResourceUri, extractResourceUri, resourceUrlFromServerUrl } from './auth-utils.js'; +import { resourceUrlFromServerUrl } from './auth-utils.js'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { it('should remove fragments', () => { - expect(resourceUrlFromServerUrl('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(resourceUrlFromServerUrl('https://example.com#fragment')).toBe('https://example.com'); - expect(resourceUrlFromServerUrl('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path#fragment')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com#fragment')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1#fragment')).href).toBe('https://example.com/path?query=1'); }); it('should return URL unchanged if no fragment', () => { - expect(resourceUrlFromServerUrl('https://example.com')).toBe('https://example.com'); - expect(resourceUrlFromServerUrl('https://example.com/path')).toBe('https://example.com/path'); - expect(resourceUrlFromServerUrl('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); + expect(resourceUrlFromServerUrl(new URL('https://example.com')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path?query=1')).href).toBe('https://example.com/path?query=1'); }); it('should keep everything else unchanged', () => { // Case sensitivity preserved - expect(resourceUrlFromServerUrl('HTTPS://EXAMPLE.COM/PATH')).toBe('HTTPS://EXAMPLE.COM/PATH'); + expect(resourceUrlFromServerUrl(new URL('https://EXAMPLE.COM/PATH')).href).toBe('https://example.com/PATH'); // Ports preserved - expect(resourceUrlFromServerUrl('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(resourceUrlFromServerUrl('https://example.com:8080/path')).toBe('https://example.com:8080/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:443/path')).href).toBe('https://example.com/path'); + expect(resourceUrlFromServerUrl(new URL('https://example.com:8080/path')).href).toBe('https://example.com:8080/path'); // Query parameters preserved - expect(resourceUrlFromServerUrl('https://example.com?foo=bar&baz=qux')).toBe('https://example.com?foo=bar&baz=qux'); + expect(resourceUrlFromServerUrl(new URL('https://example.com?foo=bar&baz=qux')).href).toBe('https://example.com/?foo=bar&baz=qux'); // Trailing slashes preserved - expect(resourceUrlFromServerUrl('https://example.com/')).toBe('https://example.com/'); - expect(resourceUrlFromServerUrl('https://example.com/path/')).toBe('https://example.com/path/'); - }); - }); - - - describe('validateResourceUri', () => { - it('should accept valid resource URIs without fragments', () => { - expect(() => validateResourceUri('https://example.com')).not.toThrow(); - expect(() => validateResourceUri('https://example.com/path')).not.toThrow(); - expect(() => validateResourceUri('http://example.com:8080')).not.toThrow(); - expect(() => validateResourceUri('https://example.com?query=1')).not.toThrow(); - expect(() => validateResourceUri('ftp://example.com')).not.toThrow(); // Only fragment check now - }); - - it('should reject URIs with fragments', () => { - expect(() => validateResourceUri('https://example.com#fragment')).toThrow('must not contain a fragment'); - expect(() => validateResourceUri('https://example.com/path#section')).toThrow('must not contain a fragment'); - expect(() => validateResourceUri('https://example.com?query=1#anchor')).toThrow('must not contain a fragment'); - }); - - it('should accept any URI without fragment', () => { - // These are all valid now since we only check for fragments - expect(() => validateResourceUri('//example.com')).not.toThrow(); - expect(() => validateResourceUri('https://user:pass@example.com')).not.toThrow(); - expect(() => validateResourceUri('/path')).not.toThrow(); - expect(() => validateResourceUri('path')).not.toThrow(); - }); - }); - - describe('extractResourceUri', () => { - it('should remove fragments from URLs', () => { - expect(extractResourceUri('https://example.com/path#fragment')).toBe('https://example.com/path'); - expect(extractResourceUri('https://example.com/path?query=1#fragment')).toBe('https://example.com/path?query=1'); - }); - - it('should handle URL object', () => { - const url = new URL('https://example.com:8443/path?query=1#fragment'); - expect(extractResourceUri(url)).toBe('https://example.com:8443/path?query=1'); - }); - - it('should keep everything else unchanged', () => { - // Preserves case - expect(extractResourceUri('HTTPS://EXAMPLE.COM/path')).toBe('HTTPS://EXAMPLE.COM/path'); - // Preserves all ports - expect(extractResourceUri('https://example.com:443/path')).toBe('https://example.com:443/path'); - expect(extractResourceUri('http://example.com:80/path')).toBe('http://example.com:80/path'); - // Preserves query parameters - expect(extractResourceUri('https://example.com/path?query=1')).toBe('https://example.com/path?query=1'); - // Preserves trailing slashes - expect(extractResourceUri('https://example.com/')).toBe('https://example.com/'); - expect(extractResourceUri('https://example.com/app1/')).toBe('https://example.com/app1/'); - }); - - it('should distinguish between different paths on same domain', () => { - // This is the key test for the security concern mentioned - const app1 = extractResourceUri('https://api.example.com/mcp-server-1'); - const app2 = extractResourceUri('https://api.example.com/mcp-server-2'); - expect(app1).not.toBe(app2); - expect(app1).toBe('https://api.example.com/mcp-server-1'); - expect(app2).toBe('https://api.example.com/mcp-server-2'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/')).href).toBe('https://example.com/'); + expect(resourceUrlFromServerUrl(new URL('https://example.com/path/')).href).toBe('https://example.com/path/'); }); }); }); \ No newline at end of file From 551a43942e412f43aee13de67ee7a796f5763831 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:33:27 +0100 Subject: [PATCH 10/41] use URL for resource throughout --- src/client/auth.test.ts | 24 +++++++++---------- src/client/sse.ts | 8 +++---- src/client/streamableHttp.ts | 8 +++---- src/server/auth/handlers/token.test.ts | 8 +++---- src/server/auth/handlers/token.ts | 4 ++-- .../auth/providers/proxyProvider.test.ts | 6 ++--- src/server/auth/providers/proxyProvider.ts | 4 ++-- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9a067405..44516130 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -347,7 +347,7 @@ describe("OAuth Authorization", () => { { clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -526,7 +526,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -650,7 +650,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -939,7 +939,7 @@ describe("OAuth Authorization", () => { // Call the auth function with a resource that has a fragment const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server#fragment", + resource: new URL("https://api.example.com/mcp-server#fragment"), }); expect(result).toBe("REDIRECT"); @@ -988,7 +988,7 @@ describe("OAuth Authorization", () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("REDIRECT"); @@ -1050,7 +1050,7 @@ describe("OAuth Authorization", () => { const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", authorizationCode: "auth-code-123", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1112,7 +1112,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server", + resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1161,7 +1161,7 @@ describe("OAuth Authorization", () => { // Call auth with empty resource parameter const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "", + resource: undefined, }); expect(result).toBe("REDIRECT"); @@ -1204,7 +1204,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing multiple # symbols const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server#fragment#another", + resource: new URL("https://api.example.com/mcp-server#fragment#another"), }); expect(result).toBe("REDIRECT"); @@ -1249,7 +1249,7 @@ describe("OAuth Authorization", () => { // multiple MCP servers on the same domain const result1 = await auth(mockProvider, { serverUrl: "https://api.example.com", - resource: "https://api.example.com/mcp-server-1/v1", + resource: new URL("https://api.example.com/mcp-server-1/v1"), }); expect(result1).toBe("REDIRECT"); @@ -1264,7 +1264,7 @@ describe("OAuth Authorization", () => { // Test with different path on same domain const result2 = await auth(mockProvider, { serverUrl: "https://api.example.com", - resource: "https://api.example.com/mcp-server-2/v1", + resource: new URL("https://api.example.com/mcp-server-2/v1"), }); expect(result2).toBe("REDIRECT"); @@ -1309,7 +1309,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing query parameters const result = await auth(mockProvider, { serverUrl: "https://resource.example.com", - resource: "https://api.example.com/mcp-server?param=value&another=test", + resource: new URL("https://api.example.com/mcp-server?param=value&another=test"), }); expect(result).toBe("REDIRECT"); diff --git a/src/client/sse.ts b/src/client/sse.ts index c484bde9..41f21de6 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { extractResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( @@ -90,7 +90,7 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +210,7 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +249,7 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 25c41bf3..3534fb45 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,7 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { extractResourceUri } from "../shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { @@ -153,7 +153,7 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +371,7 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +423,7 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: extractResourceUri(this._url) + resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 68794c36..63b47f53 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -303,7 +303,7 @@ describe('Token Handler', () => { 'valid_code', undefined, // code_verifier is undefined after PKCE validation undefined, // redirect_uri - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -371,7 +371,7 @@ describe('Token Handler', () => { 'valid_code', undefined, // code_verifier is undefined after PKCE validation 'https://example.com/callback', // redirect_uri - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -585,7 +585,7 @@ describe('Token Handler', () => { validClient, 'valid_refresh_token', undefined, // scopes - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); @@ -648,7 +648,7 @@ describe('Token Handler', () => { validClient, 'valid_refresh_token', ['profile', 'email'], // scopes - 'https://api.example.com/resource' // resource parameter + new URL('https://api.example.com/resource') // resource parameter ); }); }); diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 92fe9921..3ffd4cf2 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -113,7 +113,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand code, skipLocalPkceValidation ? code_verifier : undefined, redirect_uri, - resource + resource ? new URL(resource) : undefined ); res.status(200).json(tokens); break; @@ -131,7 +131,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand // The provider can decide how to validate it const scopes = scope?.split(" "); - const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource); + const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); break; } diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index 75dc1a15..b834c659 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -213,7 +213,7 @@ describe("Proxy OAuth Server Provider", () => { 'test-code', 'test-verifier', 'https://example.com/callback', - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); expect(global.fetch).toHaveBeenCalledWith( @@ -267,7 +267,7 @@ describe("Proxy OAuth Server Provider", () => { validClient, 'test-refresh-token', ['read', 'write'], - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); expect(global.fetch).toHaveBeenCalledWith( @@ -301,7 +301,7 @@ describe("Proxy OAuth Server Provider", () => { validClient, 'test-refresh-token', ['profile', 'email'], - 'https://api.example.com/resource' + new URL('https://api.example.com/resource') ); const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 4c807444..de74862b 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -175,7 +175,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } if (resource) { - params.append("resource", resource); + params.append("resource", resource.href); } const response = await fetch(this._endpoints.tokenUrl, { @@ -217,7 +217,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } if (resource) { - params.set("resource", resource); + params.set("resource", resource.href); } const response = await fetch(this._endpoints.tokenUrl, { From 6e4fc52c7e7433e7c4bff5b571620e51669f293a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 00:42:44 +0100 Subject: [PATCH 11/41] Update demoInMemoryOAuthProvider.test.ts --- .../server/demoInMemoryOAuthProvider.test.ts | 345 ++++-------------- 1 file changed, 78 insertions(+), 267 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 852f0c98..9bdcfdfa 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore, DemoOAuthProviderConfig } from './demoInMemoryOAuthProvider.js'; -import { InvalidTargetError, InvalidRequestError } from '../../server/auth/errors.js'; +import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; import { OAuthClientInformationFull } from '../../shared/auth.js'; import { Response } from 'express'; -describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { +describe('DemoInMemoryOAuthProvider', () => { let provider: DemoInMemoryAuthProvider; let clientsStore: DemoInMemoryClientsStore; - let mockClient: OAuthClientInformationFull & { allowed_resources?: string[] }; + let mockClient: OAuthClientInformationFull; let mockResponse: Partial; beforeEach(() => { @@ -30,132 +29,104 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { }; }); - describe('Authorization with resource parameter', () => { - it('should allow authorization when no resources are configured', async () => { + describe('Basic authorization flow', () => { + it('should handle authorization successfully', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', + resource: new URL('https://api.example.com/v1'), scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); + }, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + expect(redirectCall).toContain('code='); }); - it('should allow authorization when resource is in allowed list', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1', 'https://api.example.com/v2']; + it('should handle authorization without resource', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); + }, mockResponse as Response); expect(mockResponse.redirect).toHaveBeenCalled(); }); - it('should reject authorization when resource is not in allowed list', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1']; + it('should preserve state parameter', async () => { await clientsStore.registerClient(mockClient); - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.forbidden.com', + state: 'test-state', scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); + }, mockResponse as Response); + + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + expect(redirectCall).toContain('state=test-state'); }); }); - describe('Token exchange with resource validation', () => { + describe('Token exchange', () => { let authorizationCode: string; beforeEach(async () => { await clientsStore.registerClient(mockClient); - // Authorize without resource first await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], scopes: ['mcp:tools'] }, mockResponse as Response); - // Extract authorization code from redirect call const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; const url = new URL(redirectCall); authorizationCode = url.searchParams.get('code')!; }); - it('should exchange code successfully when resource matches', async () => { - // First authorize with a specific resource - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - + it('should exchange authorization code for tokens', async () => { const tokens = await provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.example.com/v1' + authorizationCode ); expect(tokens).toHaveProperty('access_token'); expect(tokens.token_type).toBe('bearer'); + expect(tokens.expires_in).toBe(3600); }); - it('should reject token exchange when resource does not match', async () => { - // First authorize with a specific resource - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - + it('should reject invalid authorization code', async () => { await expect(provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.different.com' - )).rejects.toThrow(InvalidTargetError); + 'invalid-code' + )).rejects.toThrow('Invalid authorization code'); }); - it('should reject token exchange when resource was not authorized but is requested', async () => { + it('should reject code from different client', async () => { + const otherClient: OAuthClientInformationFull = { + ...mockClient, + client_id: 'other-client' + }; + + await clientsStore.registerClient(otherClient); + await expect(provider.exchangeAuthorizationCode( - mockClient, - authorizationCode, - undefined, - undefined, - 'https://api.example.com/v1' - )).rejects.toThrow(InvalidTargetError); + otherClient, + authorizationCode + )).rejects.toThrow('Authorization code was not issued to this client'); }); - it('should store resource in token data', async () => { - // Authorize with resource + it('should store resource in token when provided during authorization', async () => { mockResponse.redirect = jest.fn(); await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/v1', + resource: new URL('https://api.example.com/v1'), scopes: ['mcp:tools'] }, mockResponse as Response); @@ -165,225 +136,65 @@ describe('DemoInMemoryOAuthProvider - RFC 8707 Resource Validation', () => { const tokens = await provider.exchangeAuthorizationCode( mockClient, - codeWithResource, - undefined, - undefined, - 'https://api.example.com/v1' + codeWithResource ); - // Verify token has resource information const tokenDetails = provider.getTokenDetails(tokens.access_token); - expect(tokenDetails?.resource).toBe('https://api.example.com/v1'); - }); - }); - - describe('Refresh token with resource validation', () => { - it('should validate resource when exchanging refresh token', async () => { - mockClient.allowed_resources = ['https://api.example.com/v1']; - await clientsStore.registerClient(mockClient); - - await expect(provider.exchangeRefreshToken( - mockClient, - 'refresh-token', - undefined, - 'https://api.forbidden.com' - )).rejects.toThrow(InvalidTargetError); + expect(tokenDetails?.resource).toEqual(new URL('https://api.example.com/v1')); }); }); - describe('Allowed resources management', () => { - it('should update allowed resources for a client', async () => { + describe('Token verification', () => { + it('should verify valid access token', async () => { await clientsStore.registerClient(mockClient); - // Initially no resources configured - await expect(provider.authorize(mockClient, { + await provider.authorize(mockClient, { codeChallenge: 'test-challenge', redirectUri: mockClient.redirect_uris[0], - resource: 'https://any.api.com', scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - - // Set allowed resources - clientsStore.setAllowedResources(mockClient.client_id, ['https://api.example.com/v1']); + }, mockResponse as Response); - // Now should reject unauthorized resources - await expect(provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://any.api.com', - scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); - }); - }); + const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; + const url = new URL(redirectCall); + const code = url.searchParams.get('code')!; - describe('Server URL validation configuration', () => { - it('should throw error when validateResourceMatchesServer is true but serverUrl is not set', () => { - const invalidConfig: DemoOAuthProviderConfig = { - validateResourceMatchesServer: true - // serverUrl is missing - }; + const tokens = await provider.exchangeAuthorizationCode(mockClient, code); + const tokenInfo = await provider.verifyAccessToken(tokens.access_token); - expect(() => { - new DemoInMemoryAuthProvider(invalidConfig); - }).toThrow("serverUrl must be configured when validateResourceMatchesServer is true"); + expect(tokenInfo.clientId).toBe(mockClient.client_id); + expect(tokenInfo.scopes).toEqual(['mcp:tools']); }); - describe('with server URL validation enabled', () => { - let strictProvider: DemoInMemoryAuthProvider; - - beforeEach(() => { - const config: DemoOAuthProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true - }; - strictProvider = new DemoInMemoryAuthProvider(config); - - strictProvider.clientsStore.registerClient(mockClient); - }); - - it('should reject authorization without resource parameter', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - // resource is missing - }, mockResponse as Response)).rejects.toThrow(InvalidRequestError); - }); - - it('should reject authorization with non-matching resource', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://different.api.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).rejects.toThrow(InvalidTargetError); - }); - - it('should accept authorization with matching resource', async () => { - await expect(strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - - expect(mockResponse.redirect).toHaveBeenCalled(); - }); - - it('should handle server URL with fragment correctly', async () => { - const configWithFragment: DemoOAuthProviderConfig = { - serverUrl: 'https://api.example.com/mcp#fragment', - validateResourceMatchesServer: true - }; - const providerWithFragment = new DemoInMemoryAuthProvider(configWithFragment); - - await providerWithFragment.clientsStore.registerClient(mockClient); - - // Should accept resource without fragment - await expect(providerWithFragment.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response)).resolves.not.toThrow(); - }); - - it('should reject token exchange without resource parameter', async () => { - // First authorize with resource - mockResponse.redirect = jest.fn(); - await strictProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const authCode = url.searchParams.get('code')!; - - await expect(strictProvider.exchangeAuthorizationCode( - mockClient, - authCode, - undefined, - undefined - // resource is missing - )).rejects.toThrow(InvalidRequestError); - }); - - it('should reject refresh token without resource parameter', async () => { - await expect(strictProvider.exchangeRefreshToken( - mockClient, - 'refresh-token', - undefined - // resource is missing - )).rejects.toThrow(InvalidRequestError); - }); + it('should reject invalid token', async () => { + await expect(provider.verifyAccessToken('invalid-token')) + .rejects.toThrow('Invalid or expired token'); }); + }); - describe('with server URL validation disabled (warning mode)', () => { - let warnProvider: DemoInMemoryAuthProvider; - let consoleWarnSpy: jest.SpyInstance; - - beforeEach(() => { - consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); - warnProvider = new DemoInMemoryAuthProvider(); // No config = warnings enabled - - warnProvider.clientsStore.registerClient(mockClient); - }); - - afterEach(() => { - consoleWarnSpy.mockRestore(); - }); - - it('should log warning when resource is missing from authorization', async () => { - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - // resource is missing - }, mockResponse as Response); - - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('test-client is missing the resource parameter') - ); - }); - - it('should not log warning when resource is present', async () => { - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: 'https://api.example.com/mcp', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(consoleWarnSpy).not.toHaveBeenCalled(); - }); - - it('should log warning when resource is missing from token exchange', async () => { - // First authorize without resource - await warnProvider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); + describe('Refresh token', () => { + it('should throw error for refresh token (not implemented)', async () => { + await clientsStore.registerClient(mockClient); - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const authCode = url.searchParams.get('code')!; + await expect(provider.exchangeRefreshToken( + mockClient, + 'refresh-token' + )).rejects.toThrow('Refresh tokens not implemented for example demo'); + }); + }); - await warnProvider.exchangeAuthorizationCode( - mockClient, - authCode, - undefined, - undefined - // resource is missing - ); + describe('Server URL validation', () => { + it('should accept mcpServerUrl configuration', () => { + const serverUrl = new URL('https://api.example.com/mcp'); + const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); + + expect(providerWithUrl).toBeDefined(); + }); - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('test-client is missing the resource parameter') - ); - }); + it('should handle server URL with fragment', () => { + const serverUrl = new URL('https://api.example.com/mcp#fragment'); + const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); + + expect(providerWithUrl).toBeDefined(); }); }); }); \ No newline at end of file From ec0c50425ef4119f9547fcafc16f2eda95664956 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 10:59:36 +0100 Subject: [PATCH 12/41] rm noise --- CLAUDE.local.md | 125 ---------------------------------------------- PR-DESCRIPTION.md | 111 ---------------------------------------- 2 files changed, 236 deletions(-) delete mode 100644 CLAUDE.local.md delete mode 100644 PR-DESCRIPTION.md diff --git a/CLAUDE.local.md b/CLAUDE.local.md deleted file mode 100644 index 9a43ac7c..00000000 --- a/CLAUDE.local.md +++ /dev/null @@ -1,125 +0,0 @@ -# RFC 8707 Resource Indicators Implementation for MCP TypeScript SDK - -This PR implements RFC 8707 (Resource Indicators for OAuth 2.0) in the MCP TypeScript SDK, addressing critical security vulnerabilities and adding resource-scoped authorization support. - -## Issues Addressed - -- **Fixes #592**: Implements client-side resource parameter passing to prevent token confusion attacks -- **Related to #635**: Demonstrates server-side RFC 8707 validation in the demo OAuth provider - -## Overview - -This implementation adds resource parameter support to MCP's OAuth flow, explicitly binding access tokens to specific MCP servers. This prevents malicious servers from stealing OAuth tokens intended for other services. - -## Implementation Summary - -### 1. Core Auth Infrastructure - -#### Client-Side Changes (`src/client/`) -- **auth.ts**: Added resource parameter support to authorization and token exchange flows -- **Transport layers** (sse.ts, streamableHttp.ts): Automatically extract canonical server URIs for resource parameter - -#### Server-Side Changes (`src/server/auth/`) -- **handlers/**: Updated authorize and token handlers to accept and pass through resource parameters -- **provider.ts**: Extended provider interface to support resource parameters -- **errors.ts**: Added `InvalidTargetError` for RFC 8707 compliance - -#### Shared Utilities (`src/shared/`) -- **auth-utils.ts**: Created utilities for resource URI validation and canonicalization -- **auth.ts**: Updated OAuth schemas to include resource parameter - -### 2. Demo OAuth Provider Enhancement (`src/examples/server/`) - -The demo provider demonstrates how to implement RFC 8707 validation: -- Optional resource validation during authorization (via `DemoOAuthProviderConfig`) -- Resource consistency checks during token exchange -- Resource information included in token introspection -- Support for validating resources against a configured server URL -- Client-specific resource allowlists - -### 3. Resource URI Requirements - -Resource URIs follow RFC 8707 requirements: -- **MUST NOT** include fragments (automatically removed by the SDK) -- The SDK preserves all other URL components (scheme, host, port, path, query) exactly as provided -- No additional canonicalization is performed to maintain compatibility with various server configurations - -## Client vs Server Implementation Differences - -### Client-Side Implementation -- **Automatic resource extraction**: Transports automatically determine the server URI for resource parameter -- **Transparent integration**: Resource parameter is added without changing existing auth APIs -- **Fragment removal**: Fragments are automatically removed from URIs per RFC 8707 -- **Focus**: Ensuring resource parameter is correctly included in all OAuth requests - -### Server-Side Implementation -- **Core handlers**: Pass through resource parameter without validation -- **Demo provider**: Shows how to implement resource validation -- **Provider flexibility**: Auth providers decide how to enforce resource restrictions -- **Backward compatibility**: Servers work with clients that don't send resource parameter -- **Focus**: Demonstrating best practices for resource validation - -## Testing Approach Differences - -### Client-Side Tests -- **Unit tests**: Verify resource parameter is included in auth URLs and token requests -- **Validation tests**: Ensure resource URI validation and canonicalization work correctly -- **Integration focus**: Test interaction between transport layer and auth module - -### Server-Side Tests -- **Handler tests**: Verify resource parameter is accepted and passed to providers -- **Demo provider tests**: Comprehensive tests for server URL validation and client-specific allowlists -- **Security tests**: Verify invalid resources are rejected with proper errors -- **Configuration tests**: Test various demo provider configurations -- **End-to-end tests**: Full OAuth flow with resource validation - -## Security Considerations - -1. **Token Binding**: Tokens are explicitly bound to the resource they're intended for -2. **Validation**: Both client and server validate resource URIs to prevent attacks -3. **Consistency**: Resource must match between authorization and token exchange -4. **Introspection**: Resource information is included in token introspection responses - -## Migration Guide - -### For Client Developers -No changes required - the SDK automatically includes the resource parameter based on the server URL. - -### For Server Developers -1. Core server handlers automatically pass through the resource parameter -2. Custom auth providers can implement resource validation as shown in the demo provider -3. Demo provider configuration options: - - `serverUrl`: The canonical URL of the MCP server - - `validateResourceMatchesServer`: Enable strict resource validation -4. Return `invalid_target` error for unauthorized resources -5. Include resource in token introspection responses - -## Example Usage - -```typescript -// Client automatically includes resource parameter -const transport = new StreamableHttpClientTransport( - 'https://api.example.com/mcp', - authProvider -); - -// Demo provider configuration with resource validation -const demoProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true // Makes resource required and validates it -}; -const provider = new DemoInMemoryAuthProvider(demoProviderConfig); -``` - -## Future Enhancements - -1. Add support for multiple resource parameters (RFC 8707 allows arrays) -2. Implement resource-specific scope restrictions -3. Add telemetry for resource parameter usage -4. Create migration tooling for existing deployments - -## References - -- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) -- [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) -- [MCP Issue #544 - Security Vulnerability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544) \ No newline at end of file diff --git a/PR-DESCRIPTION.md b/PR-DESCRIPTION.md deleted file mode 100644 index b4e48cbd..00000000 --- a/PR-DESCRIPTION.md +++ /dev/null @@ -1,111 +0,0 @@ -# RFC 8707 Resource Indicators Implementation - - -Implements RFC 8707 (Resource Indicators for OAuth 2.0) support in the MCP TypeScript SDK. This adds the `resource` parameter to OAuth authorization and token exchange flows, allowing access tokens to be explicitly bound to specific MCP servers. The implementation includes automatic resource extraction in client transports, server-side parameter passing, and demonstrates resource validation in the demo OAuth provider. - -(Fixes #592, Related to #635) - -## Motivation and Context - -This change addresses critical security vulnerabilities identified in https://github.com/modelcontextprotocol/modelcontextprotocol/issues/544. Without resource indicators, OAuth tokens intended for one MCP server could be stolen and misused by malicious servers. RFC 8707 prevents these token confusion attacks by explicitly binding tokens to their intended resources. - -Key problems solved: -- Prevents token theft/confusion attacks where a malicious MCP server steals tokens meant for other services -- Enables fine-grained access control by restricting OAuth clients to specific resources -- Improves security posture by following OAuth 2.0 Security Best Current Practice recommendations - -## How Has This Been Tested? - -Comprehensive test coverage has been added: - -**Client-side testing:** -- Unit tests verify resource parameter inclusion in authorization URLs and token requests (512 new lines in auth.test.ts) -- Transport layer tests ensure automatic resource extraction works correctly -- Fragment removal and URI validation tests - -**Server-side testing:** -- Authorization handler tests for resource parameter acceptance -- Token handler tests for resource parameter passing -- Demo provider tests for resource restrictions and validation (including server URL validation) -- Proxy provider tests for resource parameter forwarding - -**Integration testing:** -- End-to-end OAuth flow with resource validation -- Resource validation example demonstrating real-world usage patterns -- Tests for both clients with and without resource restrictions - -## Breaking Changes - -While the change is breaking at a protocol level, it should not require code changes from SDK users (just SDK version bumping). - -- **Client developers**: No code changes required. The SDK automatically extracts and includes the resource parameter from the server URL -- **Server developers**: The core server handlers now pass through the resource parameter. Resource validation is demonstrated in the demo provider but remains optional for custom providers -- **Auth providers**: Should be updated to accept and handle the resource parameter. The demo provider shows how to implement server URL validation and client-specific resource restrictions - -## Types of changes - -- [x] Bug fix (non-breaking change which fixes an issue) -- [x] New feature (non-breaking change which adds functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to change) -- [ ] Documentation update - -## Checklist - -- [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) -- [x] My code follows the repository's style guidelines -- [x] New and existing tests pass locally -- [x] I have added appropriate error handling -- [x] I have added or updated documentation as needed - -## Additional context - - -### Server-Side Implementation Approach - -The core server implementation focuses on passing through the resource parameter without enforcing validation, maintaining backward compatibility and flexibility. The demo provider demonstrates how to implement RFC 8707 validation: - -1. **Core Server**: Handlers accept and forward the resource parameter to auth providers without validation -2. **Demo Provider**: Shows how to implement comprehensive resource validation including: - - Server URL matching validation (configurable via `DemoOAuthProviderConfig`) - - Client-specific resource allowlists - - Warning logs for missing resource parameters - - Consistent resource validation between authorization and token exchange - -This separation allows: -- Existing providers to continue working without modification -- New providers to implement validation according to their security requirements -- Gradual migration to RFC 8707 compliance -- Different validation strategies for different deployment scenarios - -### Implementation Approach - -Resource URIs are used as-is with only fragment removal (per RFC requirement). This allows having different MCP servers under different subpaths (even w/ different query URLs) w/o sharing spilling their resource authorization to each other (to allow a variety of MCP server federation use cases). - -### Key Components Added -1. **Shared utilities** (`auth-utils.ts`): Resource URI handling and validation -2. **Client auth** modifications: Resource parameter support in authorization/token flows -3. **Transport layers**: Automatic resource extraction from server URLs -4. **Server handlers**: Resource parameter acceptance and forwarding -5. **Demo provider**: Full RFC 8707 implementation with resource validation -6. **Error handling**: New `InvalidTargetError` for RFC 8707 compliance - -### Example Usage -```typescript -// Client-side (automatic) -const transport = new StreamableHttpClientTransport( - 'https://api.example.com/mcp', - authProvider -); - -// Demo provider configuration with validation -const demoProviderConfig = { - serverUrl: 'https://api.example.com/mcp', - validateResourceMatchesServer: true // Makes resource required and validates it matches serverUrl -}; -const provider = new DemoInMemoryAuthProvider(demoProviderConfig); -``` - -### References -- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707) -- Fixes #592: OAuth token confusion vulnerability - client-side resource parameter support -- Related to #635: Demonstrates server-side RFC 8707 validation in demo provider \ No newline at end of file From b16a415623442f4f9e3f3385555afcdc30cf4fa3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:15:38 +0100 Subject: [PATCH 13/41] cleanups --- src/client/auth.test.ts | 27 +++++++------------ src/client/auth.ts | 16 ++++------- src/client/sse.ts | 3 --- src/client/streamableHttp.ts | 3 --- .../server/demoInMemoryOAuthProvider.ts | 12 +++++++-- src/examples/server/simpleStreamableHttp.ts | 7 +---- src/server/auth/handlers/authorize.ts | 3 --- src/server/auth/handlers/token.ts | 6 ----- 8 files changed, 25 insertions(+), 52 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 44516130..2cd9a2d1 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -938,8 +938,7 @@ describe("OAuth Authorization", () => { // Call the auth function with a resource that has a fragment const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server#fragment"), + serverUrl: "https://api.example.com/mcp-server#fragment", }); expect(result).toBe("REDIRECT"); @@ -987,8 +986,7 @@ describe("OAuth Authorization", () => { // Call auth without authorization code (should trigger redirect) const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server"), + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); @@ -1048,9 +1046,8 @@ describe("OAuth Authorization", () => { // Call auth with authorization code const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", + serverUrl: "https://api.example.com/mcp-server", authorizationCode: "auth-code-123", - resource: new URL("https://api.example.com/mcp-server"), }); expect(result).toBe("AUTHORIZED"); @@ -1111,8 +1108,7 @@ describe("OAuth Authorization", () => { // Call auth with existing tokens (should trigger refresh) const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server"), + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("AUTHORIZED"); @@ -1160,8 +1156,7 @@ describe("OAuth Authorization", () => { // Call auth with empty resource parameter const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: undefined, + serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); @@ -1203,8 +1198,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing multiple # symbols const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server#fragment#another"), + serverUrl: "https://api.example.com/mcp-server#fragment#another", }); expect(result).toBe("REDIRECT"); @@ -1248,8 +1242,7 @@ describe("OAuth Authorization", () => { // This tests the security fix that prevents token confusion between // multiple MCP servers on the same domain const result1 = await auth(mockProvider, { - serverUrl: "https://api.example.com", - resource: new URL("https://api.example.com/mcp-server-1/v1"), + serverUrl: "https://api.example.com/mcp-server-1/v1", }); expect(result1).toBe("REDIRECT"); @@ -1263,8 +1256,7 @@ describe("OAuth Authorization", () => { // Test with different path on same domain const result2 = await auth(mockProvider, { - serverUrl: "https://api.example.com", - resource: new URL("https://api.example.com/mcp-server-2/v1"), + serverUrl: "https://api.example.com/mcp-server-2/v1", }); expect(result2).toBe("REDIRECT"); @@ -1308,8 +1300,7 @@ describe("OAuth Authorization", () => { // Call auth with resource containing query parameters const result = await auth(mockProvider, { - serverUrl: "https://resource.example.com", - resource: new URL("https://api.example.com/mcp-server?param=value&another=test"), + serverUrl: "https://api.example.com/mcp-server?param=value&another=test", }); expect(result).toBe("REDIRECT"); diff --git a/src/client/auth.ts b/src/client/auth.ts index e465ea3b..681cde99 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -94,19 +94,13 @@ export async function auth( authorizationCode, scope, resourceMetadataUrl, - resource }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; - resourceMetadataUrl?: URL; - resource?: URL }): Promise { + resourceMetadataUrl?: URL }): Promise { - // Remove fragment from resource parameter if provided - let canonicalResource: URL | undefined; - if (resource) { - canonicalResource = resourceUrlFromServerUrl(new URL(resource)); - } + const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); let authorizationServerUrl = serverUrl; try { @@ -151,7 +145,7 @@ export async function auth( authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, - resource: canonicalResource, + resource, }); await provider.saveTokens(tokens); @@ -168,7 +162,7 @@ export async function auth( metadata, clientInformation, refreshToken: tokens.refresh_token, - resource: canonicalResource, + resource, }); await provider.saveTokens(newTokens); @@ -187,7 +181,7 @@ export async function auth( state, redirectUrl: provider.redirectUrl, scope: scope || provider.clientMetadata.scope, - resource: canonicalResource, + resource, }); await provider.saveCodeVerifier(codeVerifier); diff --git a/src/client/sse.ts b/src/client/sse.ts index 41f21de6..7a500c6b 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -90,7 +90,6 @@ export class SSEClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -210,7 +209,6 @@ export class SSEClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -249,7 +247,6 @@ export class SSEClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 3534fb45..85a0ad10 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -153,7 +153,6 @@ export class StreamableHTTPClientTransport implements Transport { result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); } catch (error) { this.onerror?.(error as Error); @@ -371,7 +370,6 @@ export class StreamableHTTPClientTransport implements Transport { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); @@ -423,7 +421,6 @@ export class StreamableHTTPClientTransport implements Transport { const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, - resource: resourceUrlFromServerUrl(new URL(this._url)) }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 316d1f8a..d6a64398 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -21,6 +21,14 @@ export class DemoInMemoryClientsStore implements OAuthRegisteredClientsStore { } } +/** + * 🚨 DEMO ONLY - NOT FOR PRODUCTION + * + * This example demonstrates MCP OAuth flow but lacks some of the features required for production use, + * for example: + * - Persistent token storage + * - Rate limiting + */ export class DemoInMemoryAuthProvider implements OAuthServerProvider { clientsStore = new DemoInMemoryClientsStore(); private codes = new Map { - throw new Error('Refresh tokens not implemented for example demo'); + throw new Error('Not implemented for example demo'); } async verifyAccessToken(token: string): Promise { diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 65b6263e..da5e740a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -282,12 +282,7 @@ if (useOAuth) { const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); - // Configure the demo auth provider to validate resources match this server - const demoProviderConfig = { - serverUrl: mcpServerUrl.href, - validateResourceMatchesServer: false // Set to true to enable strict validation - }; - const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, demoProviderConfig); + const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { verifyAccessToken: async (token: string) => { diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 17c88b45..0a6283a8 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -119,9 +119,6 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A const { scope, code_challenge, resource } = parseResult.data; state = parseResult.data.state; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - // Validate scopes let requestedScopes: string[] = []; if (scope !== undefined) { diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 3ffd4cf2..1d97805b 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -93,9 +93,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const { code, code_verifier, redirect_uri, resource } = parseResult.data; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - const skipLocalPkceValidation = provider.skipLocalPkceValidation; // Perform local PKCE validation unless explicitly skipped @@ -127,9 +124,6 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand const { refresh_token, scope, resource } = parseResult.data; - // Pass through the resource parameter to the provider - // The provider can decide how to validate it - const scopes = scope?.split(" "); const tokens = await provider.exchangeRefreshToken(client, refresh_token, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); From badb5dc990d9d782dbe41cd7f010c140e8308f53 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:23:41 +0100 Subject: [PATCH 14/41] fix tests --- src/client/auth.test.ts | 9 +++++---- src/examples/server/demoInMemoryOAuthProvider.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 2cd9a2d1..9ee4e6cf 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1125,7 +1125,7 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); - it("handles empty resource parameter", async () => { + it("handles derived resource parameter from serverUrl", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { const urlString = url.toString(); @@ -1154,17 +1154,18 @@ describe("OAuth Authorization", () => { (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - // Call auth with empty resource parameter + // Call auth with just serverUrl (resource is derived from it) const result = await auth(mockProvider, { serverUrl: "https://api.example.com/mcp-server", }); expect(result).toBe("REDIRECT"); - // Verify that empty resource is not included in the URL + // Verify that resource parameter is always included (derived from serverUrl) const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.has("resource")).toBe(false); + expect(authUrl.searchParams.has("resource")).toBe(true); + expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); }); it("handles resource with multiple fragments", async () => { diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts index 9bdcfdfa..e3a47813 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.test.ts @@ -178,7 +178,7 @@ describe('DemoInMemoryOAuthProvider', () => { await expect(provider.exchangeRefreshToken( mockClient, 'refresh-token' - )).rejects.toThrow('Refresh tokens not implemented for example demo'); + )).rejects.toThrow('Not implemented for example demo'); }); }); From 515abb492fc722bc5925d777685147d5fc746580 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 11:53:26 +0100 Subject: [PATCH 15/41] fix lints --- src/client/sse.ts | 1 - src/client/streamableHttp.ts | 1 - src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 7a500c6b..0a238d98 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -2,7 +2,6 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from "eventsource" import { Transport } from "../shared/transport.js"; import { JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; export class SseError extends Error { constructor( diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 85a0ad10..c810588f 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -2,7 +2,6 @@ import { Transport } from "../shared/transport.js"; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from "../types.js"; import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from "./auth.js"; import { EventSourceParserStream } from "eventsource-parser/stream"; -import { resourceUrlFromServerUrl } from "../shared/auth-utils.js"; // Default reconnection options for StreamableHTTP connections const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = { diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index d6a64398..3133e455 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -130,7 +130,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { _client: OAuthClientInformationFull, _refreshToken: string, _scopes?: string[], - resource?: URL + _resource?: URL ): Promise { throw new Error('Not implemented for example demo'); } From 40f61d88a0e02f0e710cf19c053c562bf47de54f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 17:05:34 +0100 Subject: [PATCH 16/41] show how to enable strict resource checking in mcp server --- .../server/demoInMemoryOAuthProvider.ts | 3 +- src/examples/server/simpleStreamableHttp.ts | 18 ++++++++++- src/server/auth/middleware/bearerAuth.test.ts | 31 +++++++++++++------ src/server/auth/middleware/bearerAuth.ts | 8 ++++- src/server/auth/provider.ts | 2 +- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 3133e455..500f59b8 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -41,7 +41,8 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { if (mcpServerUrl) { const expectedResource = resourceUrlFromServerUrl(mcpServerUrl); this.validateResource = (resource?: URL) => { - return !resource || resource.toString() !== expectedResource.toString(); + if (!resource) return false; + return resource.toString() === expectedResource.toString(); }; } } diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index da5e740a..a97fdf5a 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,6 +12,13 @@ import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); +// Resource Indicator the OAuth tokens are checked against (RFC8707). +const expectedOAuthResource = (iArg => iArg < 0 ? undefined: process.argv[iArg + 1])(process.argv.indexOf('--oauth-resource')); +// Requires Resource Indicator check (implies protocol more recent than 2025-03-26) +const strictOAuthResourceCheck = process.argv.includes('--oauth-resource-strict'); +if (strictOAuthResourceCheck && !expectedOAuthResource) { + throw new Error(`Strict resource indicator checking requires passing the expected resource with --oauth-resource https://...`); +} // Create an MCP server with implementation details const getServer = () => { @@ -285,7 +292,7 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { - verifyAccessToken: async (token: string) => { + verifyAccessToken: async (token: string, protocolVersion: string) => { const endpoint = oauthMetadata.introspection_endpoint; if (!endpoint) { @@ -308,6 +315,15 @@ if (useOAuth) { } const data = await response.json(); + + if (expectedOAuthResource) { + if (strictOAuthResourceCheck && !data.resource) { + throw new Error('Resource Indicator (RFC8707) missing'); + } + if (data.resource && data.resource !== expectedOAuthResource) { + throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); + } + } // Convert the response to AuthInfo format return { diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index b8953e5c..cae054d5 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,6 +3,7 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; +import { LATEST_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -42,12 +43,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(validAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -65,12 +67,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer expired-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -93,12 +96,13 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -115,6 +119,7 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -124,7 +129,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -146,6 +151,7 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -155,7 +161,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockRequest.auth).toEqual(authInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -204,6 +210,7 @@ describe("requireBearerAuth middleware", () => { it("should return 401 when token verification fails with InvalidTokenError", async () => { mockRequest.headers = { authorization: "Bearer invalid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); @@ -211,7 +218,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -226,6 +233,7 @@ describe("requireBearerAuth middleware", () => { it("should return 403 when access token has insufficient scopes", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); @@ -233,7 +241,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -248,6 +256,7 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when a ServerError occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); @@ -255,7 +264,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal server issue" }) @@ -266,6 +275,7 @@ describe("requireBearerAuth middleware", () => { it("should return 400 for generic OAuthError", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); @@ -273,7 +283,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" }) @@ -284,6 +294,7 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when unexpected error occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", + 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); @@ -291,7 +302,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" }) diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index fd96055a..4674089f 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -2,6 +2,7 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; +import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../../../types.js"; export type BearerAuthMiddlewareOptions = { /** @@ -50,7 +51,12 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - const authInfo = await verifier.verifyAccessToken(token); + let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; + if (Array.isArray(protocolVersion)) { + protocolVersion = protocolVersion[protocolVersion.length - 1]; + } + + const authInfo = await verifier.verifyAccessToken(token, protocolVersion); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 18beb216..409e9dae 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -80,5 +80,5 @@ export interface OAuthTokenVerifier { /** * Verifies an access token and returns information about it. */ - verifyAccessToken(token: string): Promise; + verifyAccessToken(token: string, protocolVersion: string): Promise; } From 617faccf8ec9f6e06a3164d689225f9271022ef5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 17:08:21 +0100 Subject: [PATCH 17/41] Add test for default protocol version negotiation in bearerAuth middleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tests that when mcp-protocol-version header is missing, the middleware uses DEFAULT_NEGOTIATED_PROTOCOL_VERSION when calling verifyAccessToken - Ensures proper fallback behavior for protocol version negotiation šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/server/auth/middleware/bearerAuth.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index cae054d5..665ef926 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,7 +3,7 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; -import { LATEST_PROTOCOL_VERSION } from '../../../types.js'; +import { LATEST_PROTOCOL_VERSION, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -56,6 +56,28 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.json).not.toHaveBeenCalled(); }); + it("should use default negotiated protocol version when mcp-protocol-version header is missing", async () => { + const validAuthInfo: AuthInfo = { + token: "valid-token", + clientId: "client-123", + scopes: ["read", "write"], + }; + mockVerifyAccessToken.mockResolvedValue(validAuthInfo); + + mockRequest.headers = { + authorization: "Bearer valid-token", + }; + + const middleware = requireBearerAuth({ verifier: mockVerifier }); + await middleware(mockRequest as Request, mockResponse as Response, nextFunction); + + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + expect(mockRequest.auth).toEqual(validAuthInfo); + expect(nextFunction).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(mockResponse.json).not.toHaveBeenCalled(); + }); + it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", From c2150f0cb0a5cc99ee7cdc314e51f828f5ee34f5 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Tue, 17 Jun 2025 18:41:37 +0100 Subject: [PATCH 18/41] Update README.md --- src/examples/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/examples/README.md b/src/examples/README.md index 68e1ece2..c074c757 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -76,6 +76,9 @@ npx tsx src/examples/server/simpleStreamableHttp.ts # To add a demo of authentication to this example, use: npx tsx src/examples/server/simpleStreamableHttp.ts --oauth + +# To mitigate impersonation risks, enable strict Resource Identifier verification: +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-resource=https://some-mcp-server.com --oauth-resource-strict ``` ##### JSON Response Mode Server From bf72f87788dd8179611a4dfa2af19dd639e4698a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:23:09 +0100 Subject: [PATCH 19/41] cleanups --- src/examples/README.md | 2 +- src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- src/examples/server/simpleStreamableHttp.ts | 16 +++++----------- src/server/auth/middleware/bearerAuth.ts | 7 +------ src/server/auth/provider.ts | 2 +- 5 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/examples/README.md b/src/examples/README.md index c074c757..ac92e8de 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -78,7 +78,7 @@ npx tsx src/examples/server/simpleStreamableHttp.ts npx tsx src/examples/server/simpleStreamableHttp.ts --oauth # To mitigate impersonation risks, enable strict Resource Identifier verification: -npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-resource=https://some-mcp-server.com --oauth-resource-strict +npx tsx src/examples/server/simpleStreamableHttp.ts --oauth --oauth-strict ``` ##### JSON Response Mode Server diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 500f59b8..5c34166e 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -102,7 +102,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { } if (this.validateResource && !this.validateResource(codeData.params.resource)) { - throw new Error('Invalid resource'); + throw new Error(`Invalid resource: ${codeData.params.resource}`); } this.codes.delete(authorizationCode); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index a97fdf5a..fdac5357 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -12,13 +12,7 @@ import { OAuthMetadata } from 'src/shared/auth.js'; // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); -// Resource Indicator the OAuth tokens are checked against (RFC8707). -const expectedOAuthResource = (iArg => iArg < 0 ? undefined: process.argv[iArg + 1])(process.argv.indexOf('--oauth-resource')); -// Requires Resource Indicator check (implies protocol more recent than 2025-03-26) -const strictOAuthResourceCheck = process.argv.includes('--oauth-resource-strict'); -if (strictOAuthResourceCheck && !expectedOAuthResource) { - throw new Error(`Strict resource indicator checking requires passing the expected resource with --oauth-resource https://...`); -} +const strictOAuth = process.argv.includes('--oauth-strict'); // Create an MCP server with implementation details const getServer = () => { @@ -292,7 +286,7 @@ if (useOAuth) { const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); const tokenVerifier = { - verifyAccessToken: async (token: string, protocolVersion: string) => { + verifyAccessToken: async (token: string) => { const endpoint = oauthMetadata.introspection_endpoint; if (!endpoint) { @@ -316,11 +310,11 @@ if (useOAuth) { const data = await response.json(); - if (expectedOAuthResource) { - if (strictOAuthResourceCheck && !data.resource) { + if (strictOAuth) { + if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); } - if (data.resource && data.resource !== expectedOAuthResource) { + if (data.resource !== expectedOAuthResource) { throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); } } diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index 4674089f..a34625d1 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -51,12 +51,7 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad throw new InvalidTokenError("Invalid Authorization header format, expected 'Bearer TOKEN'"); } - let protocolVersion = req.headers["mcp-protocol-version"] ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION; - if (Array.isArray(protocolVersion)) { - protocolVersion = protocolVersion[protocolVersion.length - 1]; - } - - const authInfo = await verifier.verifyAccessToken(token, protocolVersion); + const authInfo = await verifier.verifyAccessToken(token); // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 409e9dae..18beb216 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -80,5 +80,5 @@ export interface OAuthTokenVerifier { /** * Verifies an access token and returns information about it. */ - verifyAccessToken(token: string, protocolVersion: string): Promise; + verifyAccessToken(token: string): Promise; } From 4a88cac4c63bcf99a4f96452f0e70fb455df78ef Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:23:57 +0100 Subject: [PATCH 20/41] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index fdac5357..3c7318fd 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -314,8 +314,8 @@ if (useOAuth) { if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); } - if (data.resource !== expectedOAuthResource) { - throw new Error(`Expected resource indicator ${expectedOAuthResource}, got: ${data.resource}`); + if (data.resource !== mcpServerUrl) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.resource}`); } } From d58c2eb114bb56596b0dc72cd20df2ddb092e088 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:26:28 +0100 Subject: [PATCH 21/41] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 3c7318fd..9ca48fdb 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -280,7 +280,7 @@ app.use(express.json()); let authMiddleware = null; if (useOAuth) { // Create auth middleware for MCP endpoints - const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}`); + const mcpServerUrl = new URL(`http://localhost:${MCP_PORT}/mcp`); const authServerUrl = new URL(`http://localhost:${AUTH_PORT}`); const oauthMetadata: OAuthMetadata = setupAuthServer(authServerUrl, mcpServerUrl); From aebb2ab197a04c7dc9933b4f6e36e37233ef7b42 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:39:29 +0100 Subject: [PATCH 22/41] Update simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 9ca48fdb..068a0144 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -311,11 +311,11 @@ if (useOAuth) { const data = await response.json(); if (strictOAuth) { - if (!data.resource) { throw new Error('Resource Indicator (RFC8707) missing'); + if (!data.aud) { } - if (data.resource !== mcpServerUrl) { - throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.resource}`); + if (data.aud !== mcpServerUrl.href) { + throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); } } From 049170db7ca50a4148ba1e37f410926d0b90f212 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:41:09 +0100 Subject: [PATCH 23/41] minimize diff --- src/client/sse.ts | 16 +++------------- src/client/streamableHttp.ts | 11 ++--------- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/src/client/sse.ts b/src/client/sse.ts index 0a238d98..5aa99abb 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -86,10 +86,7 @@ export class SSEClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; @@ -204,11 +201,7 @@ export class SSEClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { - serverUrl: this._url, - authorizationCode, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -243,10 +236,7 @@ export class SSEClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index c810588f..f64c1ad8 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -365,11 +365,7 @@ export class StreamableHTTPClientTransport implements Transport { throw new UnauthorizedError("No auth provider"); } - const result = await auth(this._authProvider, { - serverUrl: this._url, - authorizationCode, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, authorizationCode, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError("Failed to authorize"); } @@ -417,10 +413,7 @@ export class StreamableHTTPClientTransport implements Transport { this._resourceMetadataUrl = extractResourceMetadataUrl(response); - const result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); if (result !== "AUTHORIZED") { throw new UnauthorizedError(); } From b77361bd14e65e09aaa8e600e4bce7634f591df1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:41:35 +0100 Subject: [PATCH 24/41] Update streamableHttp.ts --- src/client/streamableHttp.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index f64c1ad8..4117bb1b 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -149,10 +149,7 @@ export class StreamableHTTPClientTransport implements Transport { let result: AuthResult; try { - result = await auth(this._authProvider, { - serverUrl: this._url, - resourceMetadataUrl: this._resourceMetadataUrl, - }); + result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl }); } catch (error) { this.onerror?.(error as Error); throw error; From 8475e43f15e25e316d51f004a9465b071a3d538c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:48:20 +0100 Subject: [PATCH 25/41] drop redundant resource canonicalization tests --- src/client/auth.test.ts | 133 ---------------------------------------- 1 file changed, 133 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9ee4e6cf..c6d53343 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -907,54 +907,6 @@ describe("OAuth Authorization", () => { ); }); - it("canonicalizes resource URI by removing fragment", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { - return Promise.resolve({ - 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", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }), - }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); - - // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ - client_id: "test-client", - client_secret: "test-secret", - }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - - // Call the auth function with a resource that has a fragment - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server#fragment", - }); - - expect(result).toBe("REDIRECT"); - - // Verify redirectToAuthorization was called with the canonicalized resource - expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( - expect.objectContaining({ - searchParams: expect.any(URLSearchParams), - }) - ); - - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - it("passes resource parameter through authorization flow", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { @@ -1125,91 +1077,6 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); }); - it("handles derived resource parameter from serverUrl", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { - return Promise.resolve({ - 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", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }), - }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); - - // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ - client_id: "test-client", - client_secret: "test-secret", - }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - - // Call auth with just serverUrl (resource is derived from it) - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server", - }); - - expect(result).toBe("REDIRECT"); - - // Verify that resource parameter is always included (derived from serverUrl) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.has("resource")).toBe(true); - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - - it("handles resource with multiple fragments", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { - return Promise.resolve({ - 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", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }), - }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); - - // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ - client_id: "test-client", - client_secret: "test-secret", - }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - - // Call auth with resource containing multiple # symbols - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server#fragment#another", - }); - - expect(result).toBe("REDIRECT"); - - // Verify the resource is properly canonicalized (everything after first # removed) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); - }); - it("verifies resource parameter distinguishes between different paths on same domain", async () => { // Mock successful metadata discovery mockFetch.mockImplementation((url) => { From e5b2a5b880d21f768c868c12e0612255fd6a72ad Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:50:29 +0100 Subject: [PATCH 26/41] fix simpleStreamableHttp.ts --- src/examples/server/simpleStreamableHttp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 068a0144..9eb87d92 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -311,8 +311,8 @@ if (useOAuth) { const data = await response.json(); if (strictOAuth) { - throw new Error('Resource Indicator (RFC8707) missing'); if (!data.aud) { + throw new Error(`Resource Indicator (RFC8707) missing`); } if (data.aud !== mcpServerUrl.href) { throw new Error(`Expected resource indicator ${mcpServerUrl}, got: ${data.aud}`); From 4fcbb6870bbdd3c582346c7e66887b2660575827 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:50:39 +0100 Subject: [PATCH 27/41] verify PRM resource --- src/client/auth.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/client/auth.ts b/src/client/auth.ts index 681cde99..5fa2dee2 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -110,6 +110,9 @@ export async function auth( if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } + if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); + } } catch (error) { console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } From 68424ef7e6408a3e3b458e466a0dacdd5b8d0d99 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 11:59:19 +0100 Subject: [PATCH 28/41] simplify changes --- src/client/auth.test.ts | 88 -------- .../server/demoInMemoryOAuthProvider.test.ts | 200 ------------------ .../server/demoInMemoryOAuthProvider.ts | 11 +- 3 files changed, 1 insertion(+), 298 deletions(-) delete mode 100644 src/examples/server/demoInMemoryOAuthProvider.test.ts diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index c6d53343..ec913ecd 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -354,18 +354,6 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter when not provided", async () => { - const { authorizationUrl } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - } - ); - - expect(authorizationUrl.searchParams.has("resource")).toBe(false); - }); - it("includes scope parameter when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -535,24 +523,6 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter from token exchange when not provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokens, - }); - - await exchangeAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - authorizationCode: "code123", - codeVerifier: "verifier123", - redirectUri: "http://localhost:3000/callback", - }); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; - expect(body.has("resource")).toBe(false); - }); - it("validates token response schema", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -659,22 +629,6 @@ describe("OAuth Authorization", () => { expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); - it("excludes resource parameter from refresh token request when not provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokensWithNewRefreshToken, - }); - - await refreshAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - refreshToken: "refresh123", - }); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; - expect(body.has("resource")).toBe(false); - }); - it("exchanges refresh token for new tokens and keep existing refresh token if none is returned", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1136,47 +1090,5 @@ describe("OAuth Authorization", () => { // Verify that the two resources are different (critical for security) expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); }); - - it("preserves query parameters in resource URI", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { - return Promise.resolve({ - 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", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }), - }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); - - // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ - client_id: "test-client", - client_secret: "test-secret", - }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - - // Call auth with resource containing query parameters - const result = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server?param=value&another=test", - }); - - expect(result).toBe("REDIRECT"); - - // Verify query parameters are preserved (only fragment is removed) - const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl: URL = redirectCall[0]; - expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server?param=value&another=test"); - }); }); }); diff --git a/src/examples/server/demoInMemoryOAuthProvider.test.ts b/src/examples/server/demoInMemoryOAuthProvider.test.ts deleted file mode 100644 index e3a47813..00000000 --- a/src/examples/server/demoInMemoryOAuthProvider.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect, beforeEach } from '@jest/globals'; -import { DemoInMemoryAuthProvider, DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js'; -import { OAuthClientInformationFull } from '../../shared/auth.js'; -import { Response } from 'express'; - -describe('DemoInMemoryOAuthProvider', () => { - let provider: DemoInMemoryAuthProvider; - let clientsStore: DemoInMemoryClientsStore; - let mockClient: OAuthClientInformationFull; - let mockResponse: Partial; - - beforeEach(() => { - provider = new DemoInMemoryAuthProvider(); - clientsStore = provider.clientsStore as DemoInMemoryClientsStore; - - mockClient = { - client_id: 'test-client', - client_name: 'Test Client', - client_uri: 'https://example.com', - redirect_uris: ['https://example.com/callback'], - grant_types: ['authorization_code'], - response_types: ['code'], - scope: 'mcp:tools', - token_endpoint_auth_method: 'none', - }; - - mockResponse = { - redirect: jest.fn(), - }; - }); - - describe('Basic authorization flow', () => { - it('should handle authorization successfully', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: new URL('https://api.example.com/v1'), - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(mockResponse.redirect).toHaveBeenCalled(); - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - expect(redirectCall).toContain('code='); - }); - - it('should handle authorization without resource', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - expect(mockResponse.redirect).toHaveBeenCalled(); - }); - - it('should preserve state parameter', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - state: 'test-state', - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - expect(redirectCall).toContain('state=test-state'); - }); - }); - - describe('Token exchange', () => { - let authorizationCode: string; - - beforeEach(async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - authorizationCode = url.searchParams.get('code')!; - }); - - it('should exchange authorization code for tokens', async () => { - const tokens = await provider.exchangeAuthorizationCode( - mockClient, - authorizationCode - ); - - expect(tokens).toHaveProperty('access_token'); - expect(tokens.token_type).toBe('bearer'); - expect(tokens.expires_in).toBe(3600); - }); - - it('should reject invalid authorization code', async () => { - await expect(provider.exchangeAuthorizationCode( - mockClient, - 'invalid-code' - )).rejects.toThrow('Invalid authorization code'); - }); - - it('should reject code from different client', async () => { - const otherClient: OAuthClientInformationFull = { - ...mockClient, - client_id: 'other-client' - }; - - await clientsStore.registerClient(otherClient); - - await expect(provider.exchangeAuthorizationCode( - otherClient, - authorizationCode - )).rejects.toThrow('Authorization code was not issued to this client'); - }); - - it('should store resource in token when provided during authorization', async () => { - mockResponse.redirect = jest.fn(); - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - resource: new URL('https://api.example.com/v1'), - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const codeWithResource = url.searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode( - mockClient, - codeWithResource - ); - - const tokenDetails = provider.getTokenDetails(tokens.access_token); - expect(tokenDetails?.resource).toEqual(new URL('https://api.example.com/v1')); - }); - }); - - describe('Token verification', () => { - it('should verify valid access token', async () => { - await clientsStore.registerClient(mockClient); - - await provider.authorize(mockClient, { - codeChallenge: 'test-challenge', - redirectUri: mockClient.redirect_uris[0], - scopes: ['mcp:tools'] - }, mockResponse as Response); - - const redirectCall = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectCall); - const code = url.searchParams.get('code')!; - - const tokens = await provider.exchangeAuthorizationCode(mockClient, code); - const tokenInfo = await provider.verifyAccessToken(tokens.access_token); - - expect(tokenInfo.clientId).toBe(mockClient.client_id); - expect(tokenInfo.scopes).toEqual(['mcp:tools']); - }); - - it('should reject invalid token', async () => { - await expect(provider.verifyAccessToken('invalid-token')) - .rejects.toThrow('Invalid or expired token'); - }); - }); - - describe('Refresh token', () => { - it('should throw error for refresh token (not implemented)', async () => { - await clientsStore.registerClient(mockClient); - - await expect(provider.exchangeRefreshToken( - mockClient, - 'refresh-token' - )).rejects.toThrow('Not implemented for example demo'); - }); - }); - - describe('Server URL validation', () => { - it('should accept mcpServerUrl configuration', () => { - const serverUrl = new URL('https://api.example.com/mcp'); - const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); - - expect(providerWithUrl).toBeDefined(); - }); - - it('should handle server URL with fragment', () => { - const serverUrl = new URL('https://api.example.com/mcp#fragment'); - const providerWithUrl = new DemoInMemoryAuthProvider({ mcpServerUrl: serverUrl }); - - expect(providerWithUrl).toBeDefined(); - }); - }); -}); \ No newline at end of file diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index 5c34166e..fe8d3f9c 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -150,13 +150,6 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { resource: tokenData.resource, }; } - - /** - * Get token details including resource information (for demo introspection endpoint) - */ - getTokenDetails(token: string): AuthInfo | undefined { - return this.tokens.get(token); - } } @@ -190,14 +183,12 @@ export const setupAuthServer = (authServerUrl: URL, mcpServerUrl: URL): OAuthMet } const tokenInfo = await provider.verifyAccessToken(token); - // For demo purposes, we'll add a method to get token details - const tokenDetails = provider.getTokenDetails(token); res.json({ active: true, client_id: tokenInfo.clientId, scope: tokenInfo.scopes.join(' '), exp: tokenInfo.expiresAt, - ...(tokenDetails?.resource && { aud: tokenDetails.resource }) + aud: tokenInfo.resource, }); return } catch (error) { From 9e2a565164b121671b0f34adca0f8edc7768fffb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:01:51 +0100 Subject: [PATCH 29/41] minimize changes --- src/server/auth/handlers/authorize.test.ts | 96 +------------------ src/server/auth/middleware/bearerAuth.test.ts | 31 ++---- 2 files changed, 11 insertions(+), 116 deletions(-) diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 2742d1e5..438db6a6 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -277,7 +277,7 @@ describe('Authorization Handler', () => { }); describe('Resource parameter validation', () => { - it('accepts valid resource parameter', async () => { + it('propagates resource parameter', async () => { const mockProviderWithResource = jest.spyOn(mockProvider, 'authorize'); const response = await supertest(app) @@ -302,100 +302,6 @@ describe('Authorization Handler', () => { expect.any(Object) ); }); - - it('rejects invalid resource parameter (non-URL)', async () => { - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'not-a-url' - }); - - expect(response.status).toBe(302); - const location = new URL(response.header.location); - expect(location.searchParams.get('error')).toBe('invalid_request'); - expect(location.searchParams.get('error_description')).toContain('resource'); - }); - - it('handles authorization without resource parameter', async () => { - const mockProviderWithoutResource = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256' - }); - - expect(response.status).toBe(302); - expect(mockProviderWithoutResource).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: undefined, - redirectUri: 'https://example.com/callback', - codeChallenge: 'challenge123' - }), - expect.any(Object) - ); - }); - - it('passes multiple resources if provided', async () => { - const mockProviderWithResources = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .get('/authorize') - .query({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'https://api1.example.com/resource', - state: 'test-state' - }); - - expect(response.status).toBe(302); - expect(mockProviderWithResources).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: new URL('https://api1.example.com/resource'), - state: 'test-state' - }), - expect.any(Object) - ); - }); - - it('validates resource parameter in POST requests', async () => { - const mockProviderPost = jest.spyOn(mockProvider, 'authorize'); - - const response = await supertest(app) - .post('/authorize') - .type('form') - .send({ - client_id: 'valid-client', - redirect_uri: 'https://example.com/callback', - response_type: 'code', - code_challenge: 'challenge123', - code_challenge_method: 'S256', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(302); - expect(mockProviderPost).toHaveBeenCalledWith( - validClient, - expect.objectContaining({ - resource: new URL('https://api.example.com/resource') - }), - expect.any(Object) - ); - }); }); describe('Successful authorization', () => { diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index 665ef926..cf1a9359 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -3,7 +3,6 @@ import { requireBearerAuth } from "./bearerAuth.js"; import { AuthInfo } from "../types.js"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; -import { LATEST_PROTOCOL_VERSION, DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from '../../../types.js'; // Mock verifier const mockVerifyAccessToken = jest.fn(); @@ -43,13 +42,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(validAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -89,13 +87,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer expired-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("expired-token"); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -118,13 +115,12 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(nonExpiredAuthInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -141,7 +137,6 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -151,7 +146,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -173,7 +168,6 @@ describe("requireBearerAuth middleware", () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; const middleware = requireBearerAuth({ @@ -183,7 +177,7 @@ describe("requireBearerAuth middleware", () => { await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockRequest.auth).toEqual(authInfo); expect(nextFunction).toHaveBeenCalled(); expect(mockResponse.status).not.toHaveBeenCalled(); @@ -232,7 +226,6 @@ describe("requireBearerAuth middleware", () => { it("should return 401 when token verification fails with InvalidTokenError", async () => { mockRequest.headers = { authorization: "Bearer invalid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InvalidTokenError("Token expired")); @@ -240,7 +233,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("invalid-token"); expect(mockResponse.status).toHaveBeenCalledWith(401); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -255,7 +248,6 @@ describe("requireBearerAuth middleware", () => { it("should return 403 when access token has insufficient scopes", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new InsufficientScopeError("Required scopes: read, write")); @@ -263,7 +255,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(403); expect(mockResponse.set).toHaveBeenCalledWith( "WWW-Authenticate", @@ -278,7 +270,6 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when a ServerError occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new ServerError("Internal server issue")); @@ -286,7 +277,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal server issue" }) @@ -297,7 +288,6 @@ describe("requireBearerAuth middleware", () => { it("should return 400 for generic OAuthError", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new OAuthError("custom_error", "Some OAuth error")); @@ -305,7 +295,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(400); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "custom_error", error_description: "Some OAuth error" }) @@ -316,7 +306,6 @@ describe("requireBearerAuth middleware", () => { it("should return 500 when unexpected error occurs", async () => { mockRequest.headers = { authorization: "Bearer valid-token", - 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, }; mockVerifyAccessToken.mockRejectedValue(new Error("Unexpected error")); @@ -324,7 +313,7 @@ describe("requireBearerAuth middleware", () => { const middleware = requireBearerAuth({ verifier: mockVerifier }); await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", LATEST_PROTOCOL_VERSION); + expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token"); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith( expect.objectContaining({ error: "server_error", error_description: "Internal Server Error" }) From 6a01d0d4a59e437b135082e68d3923f9b6e9397a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:07:38 +0100 Subject: [PATCH 30/41] shrink token.test.ts --- src/client/auth.ts | 2 +- src/server/auth/handlers/token.test.ts | 68 -------------------------- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 5fa2dee2..fbe50e11 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -93,7 +93,7 @@ export async function auth( { serverUrl, authorizationCode, scope, - resourceMetadataUrl, + resourceMetadataUrl }: { serverUrl: string | URL; authorizationCode?: string; diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 63b47f53..dda4e755 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -307,74 +307,6 @@ describe('Token Handler', () => { ); }); - it('rejects invalid resource parameter (non-URL)', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - resource: 'not-a-url' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('resource'); - }); - - it('handles authorization code exchange without resource parameter', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier' - }); - - expect(response.status).toBe(200); - expect(mockExchangeCode).toHaveBeenCalledWith( - validClient, - 'valid_code', - undefined, // code_verifier is undefined after PKCE validation - undefined, // redirect_uri - undefined // resource parameter - ); - }); - - it('passes resource with redirect_uri', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - redirect_uri: 'https://example.com/callback', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); - expect(mockExchangeCode).toHaveBeenCalledWith( - validClient, - 'valid_code', - undefined, // code_verifier is undefined after PKCE validation - 'https://example.com/callback', // redirect_uri - new URL('https://api.example.com/resource') // resource parameter - ); - }); - it('passes through code verifier when using proxy provider', async () => { const originalFetch = global.fetch; From 5c60c77c93bb7479affa889ffeb3b6dd5d53233a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:09:00 +0100 Subject: [PATCH 31/41] shrink diff --- src/server/auth/middleware/bearerAuth.test.ts | 22 ------------------- src/server/auth/middleware/bearerAuth.ts | 1 - 2 files changed, 23 deletions(-) diff --git a/src/server/auth/middleware/bearerAuth.test.ts b/src/server/auth/middleware/bearerAuth.test.ts index cf1a9359..b8953e5c 100644 --- a/src/server/auth/middleware/bearerAuth.test.ts +++ b/src/server/auth/middleware/bearerAuth.test.ts @@ -54,28 +54,6 @@ describe("requireBearerAuth middleware", () => { expect(mockResponse.json).not.toHaveBeenCalled(); }); - it("should use default negotiated protocol version when mcp-protocol-version header is missing", async () => { - const validAuthInfo: AuthInfo = { - token: "valid-token", - clientId: "client-123", - scopes: ["read", "write"], - }; - mockVerifyAccessToken.mockResolvedValue(validAuthInfo); - - mockRequest.headers = { - authorization: "Bearer valid-token", - }; - - const middleware = requireBearerAuth({ verifier: mockVerifier }); - await middleware(mockRequest as Request, mockResponse as Response, nextFunction); - - expect(mockVerifyAccessToken).toHaveBeenCalledWith("valid-token", DEFAULT_NEGOTIATED_PROTOCOL_VERSION); - expect(mockRequest.auth).toEqual(validAuthInfo); - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); - expect(mockResponse.json).not.toHaveBeenCalled(); - }); - it("should reject expired tokens", async () => { const expiredAuthInfo: AuthInfo = { token: "expired-token", diff --git a/src/server/auth/middleware/bearerAuth.ts b/src/server/auth/middleware/bearerAuth.ts index a34625d1..fd96055a 100644 --- a/src/server/auth/middleware/bearerAuth.ts +++ b/src/server/auth/middleware/bearerAuth.ts @@ -2,7 +2,6 @@ import { RequestHandler } from "express"; import { InsufficientScopeError, InvalidTokenError, OAuthError, ServerError } from "../errors.js"; import { OAuthTokenVerifier } from "../provider.js"; import { AuthInfo } from "../types.js"; -import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION } from "../../../types.js"; export type BearerAuthMiddlewareOptions = { /** From 354318f147c17e5c7ea8072e7d9ada206c393aa3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:14:57 +0100 Subject: [PATCH 32/41] auth: don't fail the prm if the resource doesn't match --- src/client/auth.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index fbe50e11..297eb9cf 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -102,19 +102,20 @@ export async function auth( const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { - const resourceMetadata = await discoverOAuthProtectedResourceMetadata( - resourceMetadataUrl || serverUrl); - + resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); + } catch (error) { + console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) + } + if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); } - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } const metadata = await discoverOAuthMetadata(authorizationServerUrl); From bac384f242d96a09a94dff184cc1dcbd927c0bbe Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 12:23:48 +0100 Subject: [PATCH 33/41] simplify tests --- src/client/auth.test.ts | 60 ----------------------------------------- 1 file changed, 60 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index ec913ecd..9cdc9e05 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1030,65 +1030,5 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); - - it("verifies resource parameter distinguishes between different paths on same domain", async () => { - // Mock successful metadata discovery - mockFetch.mockImplementation((url) => { - const urlString = url.toString(); - if (urlString.includes("/.well-known/oauth-authorization-server")) { - return Promise.resolve({ - 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", - response_types_supported: ["code"], - code_challenge_methods_supported: ["S256"], - }), - }); - } - return Promise.resolve({ ok: false, status: 404 }); - }); - - // Mock provider methods - (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ - client_id: "test-client", - client_secret: "test-secret", - }); - (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); - (mockProvider.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); - (mockProvider.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); - - // Test with different resource paths on same domain - // This tests the security fix that prevents token confusion between - // multiple MCP servers on the same domain - const result1 = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server-1/v1", - }); - - expect(result1).toBe("REDIRECT"); - - const redirectCall1 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl1: URL = redirectCall1[0]; - expect(authUrl1.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-1/v1"); - - // Clear mock calls - (mockProvider.redirectToAuthorization as jest.Mock).mockClear(); - - // Test with different path on same domain - const result2 = await auth(mockProvider, { - serverUrl: "https://api.example.com/mcp-server-2/v1", - }); - - expect(result2).toBe("REDIRECT"); - - const redirectCall2 = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; - const authUrl2: URL = redirectCall2[0]; - expect(authUrl2.searchParams.get("resource")).toBe("https://api.example.com/mcp-server-2/v1"); - - // Verify that the two resources are different (critical for security) - expect(authUrl1.searchParams.get("resource")).not.toBe(authUrl2.searchParams.get("resource")); - }); }); }); From a7f9c59401a4722b673751a2a3bf21ef91e4eca1 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:16:32 +0100 Subject: [PATCH 34/41] Fix SSE test resource URL validation errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed 5 instances of hardcoded "https://resource.example.com" in OAuth protected resource metadata mocks to use the actual resourceBaseUrl.href. This resolves test failures where the auth validation was rejecting requests because the resource URL in the metadata didn't match the actual test server URL. The failing tests were: - attempts auth flow on 401 during SSE connection - attempts auth flow on 401 during POST request - refreshes expired token during SSE connection - refreshes expired token during POST request - redirects to authorization if refresh token flow fails All SSE tests now pass (17/17). šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/sse.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/client/sse.test.ts b/src/client/sse.test.ts index 714e1fdd..3cb4e8a3 100644 --- a/src/client/sse.test.ts +++ b/src/client/sse.test.ts @@ -398,7 +398,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -450,7 +450,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -601,7 +601,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -723,7 +723,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; @@ -851,7 +851,7 @@ describe("SSEClientTransport", () => { 'Content-Type': 'application/json', }) .end(JSON.stringify({ - resource: "https://resource.example.com", + resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`], })); return; From f0ea31cff96d5aee6bfd1a885dcf267b4be4c188 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:19:56 +0100 Subject: [PATCH 35/41] Update auth.test.ts --- src/client/auth.test.ts | 55 +++-------------------------------------- 1 file changed, 4 insertions(+), 51 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 9cdc9e05..cb726717 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -324,6 +324,7 @@ describe("OAuth Authorization", () => { metadata: undefined, clientInformation: validClientInfo, redirectUrl: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), } ); @@ -338,20 +339,8 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("redirect_uri")).toBe( "http://localhost:3000/callback" ); - expect(codeVerifier).toBe("test_verifier"); - }); - - it("includes resource parameter when provided", async () => { - const { authorizationUrl } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - resource: new URL("https://api.example.com/mcp-server"), - } - ); - expect(authorizationUrl.searchParams.get("resource")).toBe("https://api.example.com/mcp-server"); + expect(codeVerifier).toBe("test_verifier"); }); it("includes scope parameter when provided", async () => { @@ -478,6 +467,7 @@ describe("OAuth Authorization", () => { authorizationCode: "code123", codeVerifier: "verifier123", redirectUri: "http://localhost:3000/callback", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokens); @@ -500,26 +490,6 @@ describe("OAuth Authorization", () => { expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback"); - }); - - it("includes resource parameter in token exchange when provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokens, - }); - - const tokens = await exchangeAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - authorizationCode: "code123", - codeVerifier: "verifier123", - redirectUri: "http://localhost:3000/callback", - resource: new URL("https://api.example.com/mcp-server"), - }); - - expect(tokens).toEqual(validTokens); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); @@ -588,6 +558,7 @@ describe("OAuth Authorization", () => { const tokens = await refreshAuthorization("https://auth.example.com", { clientInformation: validClientInfo, refreshToken: "refresh123", + resource: new URL("https://api.example.com/mcp-server"), }); expect(tokens).toEqual(validTokensWithNewRefreshToken); @@ -608,24 +579,6 @@ describe("OAuth Authorization", () => { expect(body.get("refresh_token")).toBe("refresh123"); expect(body.get("client_id")).toBe("client123"); expect(body.get("client_secret")).toBe("secret123"); - }); - - it("includes resource parameter in refresh token request when provided", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => validTokensWithNewRefreshToken, - }); - - const tokens = await refreshAuthorization("https://auth.example.com", { - clientInformation: validClientInfo, - refreshToken: "refresh123", - resource: new URL("https://api.example.com/mcp-server"), - }); - - expect(tokens).toEqual(validTokensWithNewRefreshToken); - - const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); }); From 3f07bdb223c2ff0d55f935fd427ad961b1b218cb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 13:32:45 +0100 Subject: [PATCH 36/41] shrink tests --- src/server/auth/handlers/token.test.ts | 108 ++---------------- .../auth/providers/proxyProvider.test.ts | 72 +----------- 2 files changed, 8 insertions(+), 172 deletions(-) diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index dda4e755..4b7fae02 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -264,12 +264,14 @@ describe('Token Handler', () => { }); it('returns tokens for valid code exchange', async () => { + const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'authorization_code', code: 'valid_code', code_verifier: 'valid_verifier' @@ -280,24 +282,6 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('mock_refresh_token'); - }); - - it('accepts and passes resource parameter to provider', async () => { - const mockExchangeCode = jest.spyOn(mockProvider, 'exchangeAuthorizationCode'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'authorization_code', - code: 'valid_code', - code_verifier: 'valid_verifier', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); expect(mockExchangeCode).toHaveBeenCalledWith( validClient, 'valid_code', @@ -465,12 +449,14 @@ describe('Token Handler', () => { }); it('returns new tokens for valid refresh token', async () => { + const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); const response = await supertest(app) .post('/token') .type('form') .send({ client_id: 'valid-client', client_secret: 'valid-secret', + resource: 'https://api.example.com/resource', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token' }); @@ -480,39 +466,6 @@ describe('Token Handler', () => { expect(response.body.token_type).toBe('bearer'); expect(response.body.expires_in).toBe(3600); expect(response.body.refresh_token).toBe('new_mock_refresh_token'); - }); - - it('respects requested scopes on refresh', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - scope: 'profile email' - }); - - expect(response.status).toBe(200); - expect(response.body.scope).toBe('profile email'); - }); - - it('accepts and passes resource parameter to provider on refresh', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - resource: 'https://api.example.com/resource' - }); - - expect(response.status).toBe(200); expect(mockExchangeRefresh).toHaveBeenCalledWith( validClient, 'valid_refresh_token', @@ -521,48 +474,7 @@ describe('Token Handler', () => { ); }); - it('rejects invalid resource parameter (non-URL) on refresh', async () => { - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token', - resource: 'not-a-url' - }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe('invalid_request'); - expect(response.body.error_description).toContain('resource'); - }); - - it('handles refresh token exchange without resource parameter', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - - const response = await supertest(app) - .post('/token') - .type('form') - .send({ - client_id: 'valid-client', - client_secret: 'valid-secret', - grant_type: 'refresh_token', - refresh_token: 'valid_refresh_token' - }); - - expect(response.status).toBe(200); - expect(mockExchangeRefresh).toHaveBeenCalledWith( - validClient, - 'valid_refresh_token', - undefined, // scopes - undefined // resource parameter - ); - }); - - it('passes resource with scopes on refresh', async () => { - const mockExchangeRefresh = jest.spyOn(mockProvider, 'exchangeRefreshToken'); - + it('respects requested scopes on refresh', async () => { const response = await supertest(app) .post('/token') .type('form') @@ -571,17 +483,11 @@ describe('Token Handler', () => { client_secret: 'valid-secret', grant_type: 'refresh_token', refresh_token: 'valid_refresh_token', - scope: 'profile email', - resource: 'https://api.example.com/resource' + scope: 'profile email' }); expect(response.status).toBe(200); - expect(mockExchangeRefresh).toHaveBeenCalledWith( - validClient, - 'valid_refresh_token', - ['profile', 'email'], // scopes - new URL('https://api.example.com/resource') // resource parameter - ); + expect(response.body.scope).toBe('profile email'); }); }); diff --git a/src/server/auth/providers/proxyProvider.test.ts b/src/server/auth/providers/proxyProvider.test.ts index b834c659..4e98d0dc 100644 --- a/src/server/auth/providers/proxyProvider.test.ts +++ b/src/server/auth/providers/proxyProvider.test.ts @@ -88,6 +88,7 @@ describe("Proxy OAuth Server Provider", () => { codeChallenge: "test-challenge", state: "test-state", scopes: ["read", "write"], + resource: new URL('https://api.example.com/resource'), }, mockResponse ); @@ -100,52 +101,10 @@ describe("Proxy OAuth Server Provider", () => { expectedUrl.searchParams.set("code_challenge_method", "S256"); expectedUrl.searchParams.set("state", "test-state"); expectedUrl.searchParams.set("scope", "read write"); - - expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); - }); - - it('includes resource parameter in authorization redirect', async () => { - await provider.authorize( - validClient, - { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - state: 'test-state', - scopes: ['read', 'write'], - resource: new URL('https://api.example.com/resource') - }, - mockResponse - ); - - const expectedUrl = new URL('https://auth.example.com/authorize'); - expectedUrl.searchParams.set('client_id', 'test-client'); - expectedUrl.searchParams.set('response_type', 'code'); - expectedUrl.searchParams.set('redirect_uri', 'https://example.com/callback'); - expectedUrl.searchParams.set('code_challenge', 'test-challenge'); - expectedUrl.searchParams.set('code_challenge_method', 'S256'); - expectedUrl.searchParams.set('state', 'test-state'); - expectedUrl.searchParams.set('scope', 'read write'); expectedUrl.searchParams.set('resource', 'https://api.example.com/resource'); expect(mockResponse.redirect).toHaveBeenCalledWith(expectedUrl.toString()); }); - - it('handles authorization without resource parameter', async () => { - await provider.authorize( - validClient, - { - redirectUri: 'https://example.com/callback', - codeChallenge: 'test-challenge', - state: 'test-state', - scopes: ['read'] - }, - mockResponse - ); - - const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0]; - const url = new URL(redirectUrl); - expect(url.searchParams.has('resource')).toBe(false); - }); }); describe("token exchange", () => { @@ -282,35 +241,6 @@ describe("Proxy OAuth Server Provider", () => { ); expect(tokens).toEqual(mockTokenResponse); }); - - it('handles refresh token exchange without resource parameter', async () => { - const tokens = await provider.exchangeRefreshToken( - validClient, - 'test-refresh-token', - ['read'] - ); - - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; - const body = fetchCall[1].body as string; - expect(body).not.toContain('resource='); - expect(tokens).toEqual(mockTokenResponse); - }); - - it('includes both scope and resource parameters in refresh', async () => { - const tokens = await provider.exchangeRefreshToken( - validClient, - 'test-refresh-token', - ['profile', 'email'], - new URL('https://api.example.com/resource') - ); - - const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; - const body = fetchCall[1].body as string; - expect(body).toContain('scope=profile+email'); - expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/resource')); - expect(tokens).toEqual(mockTokenResponse); - }); - }); describe("client registration", () => { From 4b3db9bbebb1fa93e0e59841a3fc8842996ba43f Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 15:06:16 +0100 Subject: [PATCH 37/41] stricter PRM check overridable w/ OAuthClientProvider.validateProtectedResourceMetadata --- src/client/auth.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 297eb9cf..7097eab0 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -72,6 +72,13 @@ export interface OAuthClientProvider { * the authorization result. */ codeVerifier(): string | Promise; + + /** + * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). + * + * Implementations must verify the provider + */ + validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -109,11 +116,13 @@ export async function auth( } catch (error) { console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } - if (resourceMetadata) { + if (provider.validateProtectedResourceMetadata) { + await provider.validateProtectedResourceMetadata(resourceMetadata); + } else if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - if (resourceMetadata.resource && resourceMetadata.resource !== resource.href) { + if (resourceMetadata.resource !== resource.href) { throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); } } From f854b58443a856ef06f58e33e8f49c40f74d9eb9 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Wed, 18 Jun 2025 15:26:58 +0100 Subject: [PATCH 38/41] test validateProtectedResourceMetadata override --- src/client/auth.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++ src/client/auth.ts | 2 +- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index cb726717..194c1124 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -983,5 +983,66 @@ describe("OAuth Authorization", () => { expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); + + it("skips default PRM resource validation when custom validateProtectedResourceMetadata is provided", async () => { + const mockValidateProtectedResourceMetadata = jest.fn().mockResolvedValue(undefined); + const providerWithCustomValidation = { + ...mockProvider, + validateProtectedResourceMetadata: mockValidateProtectedResourceMetadata, + }; + + // Mock protected resource metadata with mismatched resource URL + // This would normally throw an error in default validation, but should be skipped + mockFetch.mockImplementation((url) => { + const urlString = url.toString(); + + if (urlString.includes("/.well-known/oauth-protected-resource")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: "https://different-resource.example.com/mcp-server", // Mismatched resource + authorization_servers: ["https://auth.example.com"], + }), + }); + } else if (urlString.includes("/.well-known/oauth-authorization-server")) { + return Promise.resolve({ + 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", + response_types_supported: ["code"], + code_challenge_methods_supported: ["S256"], + }), + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + // Mock provider methods + (providerWithCustomValidation.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + (providerWithCustomValidation.tokens as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.saveCodeVerifier as jest.Mock).mockResolvedValue(undefined); + (providerWithCustomValidation.redirectToAuthorization as jest.Mock).mockResolvedValue(undefined); + + // Call auth - should succeed despite resource mismatch because custom validation overrides default + const result = await auth(providerWithCustomValidation, { + serverUrl: "https://api.example.com/mcp-server", + }); + + expect(result).toBe("REDIRECT"); + + // Verify custom validation method was called + expect(mockValidateProtectedResourceMetadata).toHaveBeenCalledWith({ + resource: "https://different-resource.example.com/mcp-server", + authorization_servers: ["https://auth.example.com"], + }); + }); }); }); diff --git a/src/client/auth.ts b/src/client/auth.ts index 7097eab0..4d604d28 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -76,7 +76,7 @@ export interface OAuthClientProvider { /** * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). * - * Implementations must verify the provider + * Implementations must verify the resource matches the MCP server. */ validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; } From dada5f66f570f312a910bae095c481915b2f80c4 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 15:52:56 +0100 Subject: [PATCH 39/41] wip helper func --- src/client/auth.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index 4d604d28..bef7965f 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -78,7 +78,7 @@ export interface OAuthClientProvider { * * Implementations must verify the resource matches the MCP server. */ - validateProtectedResourceMetadata?(metadata?: OAuthProtectedResourceMetadata): Promise; + validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -107,26 +107,19 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL }): Promise { - const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); - let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl = serverUrl; try { resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl}); - } catch (error) { - console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) - } - if (provider.validateProtectedResourceMetadata) { - await provider.validateProtectedResourceMetadata(resourceMetadata); - } else if (resourceMetadata) { if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } - if (resourceMetadata.resource !== resource.href) { - throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); - } + } catch (error) { + console.warn("Could not load OAuth Protected Resource metadata, falling back to /.well-known/oauth-authorization-server", error) } + const resource: URL | undefined = await selectResourceURL(serverUrl, provider, resourceMetadata); + const metadata = await discoverOAuthMetadata(authorizationServerUrl); // Handle client registration if needed @@ -202,6 +195,19 @@ export async function auth( return "REDIRECT"; } +async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { + if (provider.validateResourceURL) { + return await provider.validateResourceURL(serverUrl, resourceMetadata?.resource); + } + + const resource = resourceUrlFromServerUrl(typeof serverUrl === "string" ? new URL(serverUrl) : serverUrl); + if (resourceMetadata && resourceMetadata.resource !== resource.href) { + throw new Error(`Protected resource ${resourceMetadata.resource} does not match expected ${resource}`); + } + + return resource; +} + /** * Extract resource_metadata from response header. */ From 4c51230fc2022f15573eea6ec6c7b9f3bbbdea91 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 16:00:12 +0100 Subject: [PATCH 40/41] fix tests --- src/client/auth.test.ts | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 194c1124..91422de0 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -849,7 +849,7 @@ describe("OAuth Authorization", () => { }); expect(result).toBe("REDIRECT"); - + // Verify the authorization URL includes the resource parameter expect(mockProvider.redirectToAuthorization).toHaveBeenCalledWith( expect.objectContaining({ @@ -866,7 +866,7 @@ describe("OAuth Authorization", () => { // Mock successful metadata discovery and token exchange mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, @@ -891,7 +891,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -912,11 +912,11 @@ describe("OAuth Authorization", () => { expect(result).toBe("AUTHORIZED"); // Find the token exchange call - const tokenCall = mockFetch.mock.calls.find(call => + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes("/token") ); expect(tokenCall).toBeDefined(); - + const body = tokenCall![1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); expect(body.get("code")).toBe("auth-code-123"); @@ -926,7 +926,7 @@ describe("OAuth Authorization", () => { // Mock successful metadata discovery and token refresh mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-authorization-server")) { return Promise.resolve({ ok: true, @@ -950,7 +950,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -973,29 +973,29 @@ describe("OAuth Authorization", () => { expect(result).toBe("AUTHORIZED"); // Find the token refresh call - const tokenCall = mockFetch.mock.calls.find(call => + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes("/token") ); expect(tokenCall).toBeDefined(); - + const body = tokenCall![1].body as URLSearchParams; expect(body.get("resource")).toBe("https://api.example.com/mcp-server"); expect(body.get("grant_type")).toBe("refresh_token"); expect(body.get("refresh_token")).toBe("refresh123"); }); - it("skips default PRM resource validation when custom validateProtectedResourceMetadata is provided", async () => { - const mockValidateProtectedResourceMetadata = jest.fn().mockResolvedValue(undefined); + it("skips default PRM resource validation when custom validateResourceURL is provided", async () => { + const mockValidateResourceURL = jest.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { ...mockProvider, - validateProtectedResourceMetadata: mockValidateProtectedResourceMetadata, + validateResourceURL: mockValidateResourceURL, }; // Mock protected resource metadata with mismatched resource URL // This would normally throw an error in default validation, but should be skipped mockFetch.mockImplementation((url) => { const urlString = url.toString(); - + if (urlString.includes("/.well-known/oauth-protected-resource")) { return Promise.resolve({ ok: true, @@ -1018,7 +1018,7 @@ describe("OAuth Authorization", () => { }), }); } - + return Promise.resolve({ ok: false, status: 404 }); }); @@ -1037,12 +1037,12 @@ describe("OAuth Authorization", () => { }); expect(result).toBe("REDIRECT"); - + // Verify custom validation method was called - expect(mockValidateProtectedResourceMetadata).toHaveBeenCalledWith({ - resource: "https://different-resource.example.com/mcp-server", - authorization_servers: ["https://auth.example.com"], - }); + expect(mockValidateResourceURL).toHaveBeenCalledWith( + "https://api.example.com/mcp-server", + "https://different-resource.example.com/mcp-server" + ); }); }); }); From 86bed6aaacd4491cbd0621e24836fdcc5cd1ca34 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 18 Jun 2025 16:05:14 +0100 Subject: [PATCH 41/41] adjust comment --- src/client/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/client/auth.ts b/src/client/auth.ts index bef7965f..28d9d833 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -74,9 +74,11 @@ export interface OAuthClientProvider { codeVerifier(): string | Promise; /** - * If defined, overrides the OAuth Protected Resource Metadata (RFC 9728). + * If defined, overrides the selection and validation of the + * RFC 8707 Resource Indicator. If left undefined, default + * validation behavior will be used. * - * Implementations must verify the resource matches the MCP server. + * Implementations must verify the returned resource matches the MCP server. */ validateResourceURL?(serverUrl: string | URL, resource?: string): Promise; }