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