From b5c62d9022acb575f601a73385d6b1dccb71ca36 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 25 Oct 2025 22:29:51 +0200 Subject: [PATCH 1/4] feat: implement SEP-985 OAuth 2.0 Protected Resource Metadata fallback Aligns OAuth 2.0 Protected Resource Metadata handling with RFC 9728 and SEP-985 by making the WWW-Authenticate header optional and implementing graceful fallback behavior. Changes: - Updated discoverOAuthProtectedResourceMetadata() to return undefined instead of throwing on 404, making protected resource metadata optional - Enhanced JSDoc comments to document SEP-985 fallback behavior - Updated authInternal() to handle optional metadata with proper null checks - Added comprehensive test suite for SEP-985 scenarios: - WWW-Authenticate header with resource_metadata present - WWW-Authenticate header without resource_metadata (fallback to well-known) - Missing WWW-Authenticate header (fallback to well-known) - 404 on well-known endpoint (graceful degradation) - CORS errors on metadata discovery (graceful fallback) - Updated existing tests to expect undefined instead of errors on 404 Per SEP-985, clients now: 1. Check WWW-Authenticate header for resource_metadata parameter 2. Fallback to /.well-known/oauth-protected-resource if not present 3. Continue gracefully using the MCP server as auth server if metadata unavailable All 856 tests pass. Related: #920 Implements: SEP-985 (modelcontextprotocol/modelcontextprotocol#971) --- src/client/auth.test.ts | 250 ++++++++++++++++++++++++++++++++++++---- src/client/auth.ts | 41 +++++-- 2 files changed, 261 insertions(+), 30 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f2dadbb15..f0b876361 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -147,15 +147,14 @@ describe('OAuth Authorization', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it('throws on 404 errors', async () => { + it('returns undefined on 404 errors (per SEP-985)', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( - 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' - ); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + expect(metadata).toBeUndefined(); }); it('throws on non-404 errors', async () => { @@ -248,7 +247,7 @@ describe('OAuth Authorization', () => { } ); - it('throws error when both path-aware and root discovery return 404', async () => { + it('returns undefined when both path-aware and root discovery return 404 (per SEP-985)', async () => { // First call (path-aware) returns 404 mockFetch.mockResolvedValueOnce({ ok: false, @@ -261,9 +260,8 @@ describe('OAuth Authorization', () => { status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name')).rejects.toThrow( - 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' - ); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path/name'); + expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; expect(calls.length).toBe(2); @@ -282,16 +280,15 @@ describe('OAuth Authorization', () => { expect(calls.length).toBe(1); // Should not attempt fallback }); - it('does not fallback when the original URL is already at root path', async () => { + it('returns undefined when the original URL is already at root path and returns 404 (per SEP-985)', async () => { // First call (path-aware for root) returns 404 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com/')).rejects.toThrow( - 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' - ); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/'); + expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback @@ -300,16 +297,15 @@ describe('OAuth Authorization', () => { expect(url.toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); }); - it('does not fallback when the original URL has no path', async () => { + it('returns undefined when the original URL has no path and returns 404 (per SEP-985)', async () => { // First call (path-aware for no path) returns 404 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); - await expect(discoverOAuthProtectedResourceMetadata('https://resource.example.com')).rejects.toThrow( - 'Resource server does not implement OAuth 2.0 Protected Resource Metadata.' - ); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com'); + expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback @@ -349,18 +345,17 @@ describe('OAuth Authorization', () => { }); }); - it('does not fallback when resourceMetadataUrl is provided', async () => { + it('returns undefined when resourceMetadataUrl is provided but returns 404 (per SEP-985)', async () => { // Call with explicit URL returns 404 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); - await expect( - discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', { - resourceMetadataUrl: 'https://custom.example.com/metadata' - }) - ).rejects.toThrow('Resource server does not implement OAuth 2.0 Protected Resource Metadata.'); + const metadata = await discoverOAuthProtectedResourceMetadata('https://resource.example.com/path', { + resourceMetadataUrl: 'https://custom.example.com/metadata' + }); + expect(metadata).toBeUndefined(); const calls = mockFetch.mock.calls; expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided @@ -2180,6 +2175,217 @@ describe('OAuth Authorization', () => { // Verify custom fetch was called for AS metadata discovery expect(customFetch.mock.calls[1][0].toString()).toBe('https://auth.example.com/.well-known/oauth-authorization-server'); }); + + describe('SEP-985: WWW-Authenticate fallback behavior', () => { + it('uses resource_metadata URL from WWW-Authenticate header when provided', async () => { + // Mock PRM discovery from explicit URL + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/custom/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + // Pass resourceMetadataUrl extracted from WWW-Authenticate header + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com', + resourceMetadataUrl: new URL('https://resource.example.com/custom/.well-known/oauth-protected-resource') + }); + + expect(result).toBe('REDIRECT'); + + // Verify that the custom URL was used, not the default well-known path + const firstCall = mockFetch.mock.calls[0]; + expect(firstCall[0].toString()).toBe('https://resource.example.com/custom/.well-known/oauth-protected-resource'); + }); + + it('falls back to well-known when WWW-Authenticate header has no resource_metadata', async () => { + // Simulate: WWW-Authenticate present but without resource_metadata parameter + // In this case, resourceMetadataUrl would be undefined + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + // No resourceMetadataUrl provided (as if WWW-Authenticate had no resource_metadata) + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify fallback to well-known path + const firstCall = mockFetch.mock.calls[0]; + expect(firstCall[0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + }); + + it('continues gracefully when well-known PRM returns 404', async () => { + // Per SEP-985, protected resource metadata is optional + // Client should fall back to using server as auth server + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + // PRM not available - return 404 + return Promise.resolve({ + ok: false, + status: 404 + }); + } else if (urlString === 'https://resource.example.com/.well-known/oauth-authorization-server') { + // Fall back to server as auth server + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + registration_endpoint: 'https://resource.example.com/register', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString === 'https://resource.example.com/register') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + client_id: 'registered-client-id', + client_secret: 'registered-secret', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + (mockProvider.clientInformation as jest.Mock).mockResolvedValue(undefined); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + mockProvider.saveClientInformation = jest.fn(); + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify we tried PRM discovery + expect(mockFetch.mock.calls[0][0].toString()).toBe('https://resource.example.com/.well-known/oauth-protected-resource'); + + // Verify we fell back to auth server metadata on same server + expect(mockFetch.mock.calls[1][0].toString()).toBe('https://resource.example.com/.well-known/oauth-authorization-server'); + + // Verify client registration happened + expect(mockProvider.saveClientInformation).toHaveBeenCalled(); + }); + + it('handles CORS error on PRM discovery and falls back gracefully', async () => { + let callCount = 0; + + mockFetch.mockImplementation(url => { + callCount++; + const urlString = url.toString(); + + if (callCount <= 2 && urlString.includes('oauth-protected-resource')) { + // Simulate CORS error on PRM discovery (both with and without headers) + return Promise.reject(new TypeError('Network request failed')); + } else if (urlString.includes('oauth-authorization-server')) { + // Auth server metadata succeeds + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://resource.example.com', + authorization_endpoint: 'https://resource.example.com/authorize', + token_endpoint: 'https://resource.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify we tried PRM discovery (with retry for CORS) + expect(mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')).length).toBeGreaterThan(0); + + // Verify we eventually fell back to auth server metadata + expect(mockFetch.mock.calls.some(call => call[0].toString().includes('oauth-authorization-server'))).toBe(true); + }); + }); }); describe('exchangeAuthorization with multiple client authentication methods', () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 1e90f34ba..b6534f648 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -334,16 +334,17 @@ async function authInternal( let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL | undefined; try { + // Per SEP-985: Try WWW-Authenticate header first, fallback to well-known if not present resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl }, fetchFn); - if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) { + if (resourceMetadata?.authorization_servers && resourceMetadata.authorization_servers.length > 0) { authorizationServerUrl = resourceMetadata.authorization_servers[0]; } } catch { - // Ignore errors and fall back to /.well-known/oauth-authorization-server + // Ignore non-404 errors and fall back to using the MCP server as the authorization server } /** - * If we don't get a valid authorization server metadata from protected resource metadata, + * If we don't get a valid authorization server from protected resource metadata, * fallback to the legacy MCP spec's implementation (version 2025-03-26): MCP server acts as the Authorization server. */ if (!authorizationServerUrl) { @@ -466,7 +467,19 @@ export async function selectResourceURL( } /** - * Extract resource_metadata from response header. + * Extracts resource_metadata URL from WWW-Authenticate response header. + * + * Per SEP-985 and RFC 9728 Section 5, servers SHOULD return the WWW-Authenticate + * header with resource_metadata parameter on 401 responses, but it's optional. + * Clients must fallback to /.well-known/oauth-protected-resource if not present. + * + * Expected header format: + * ``` + * WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource" + * ``` + * + * @param res - HTTP Response object to extract the header from + * @returns The resource metadata URL if present, undefined otherwise */ export function extractResourceMetadataUrl(res: Response): URL | undefined { const authenticateHeader = res.headers.get('WWW-Authenticate'); @@ -495,21 +508,33 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { /** * Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata. * - * If the server returns a 404 for the well-known endpoint, this function will - * return `undefined`. Any other errors will be thrown as exceptions. + * Per SEP-985 and RFC 9728, protected resource metadata is optional. + * This function returns `undefined` if: + * - The server returns a 404 for the well-known endpoint + * - The metadata endpoint is not accessible (CORS, network errors, etc.) + * - No resourceMetadataUrl is provided and the well-known endpoint is not found + * + * Other HTTP errors (5xx, 400-403, etc.) will be thrown as exceptions. + * + * @param serverUrl - The MCP server URL + * @param opts - Optional configuration including protocol version and explicit metadata URL + * @param fetchFn - Optional fetch function for making HTTP requests + * @returns Promise resolving to metadata or undefined if not available + * @throws Error for non-404 HTTP errors */ export async function discoverOAuthProtectedResourceMetadata( serverUrl: string | URL, opts?: { protocolVersion?: string; resourceMetadataUrl?: string | URL }, fetchFn: FetchLike = fetch -): Promise { +): Promise { const response = await discoverMetadataWithFallback(serverUrl, 'oauth-protected-resource', fetchFn, { protocolVersion: opts?.protocolVersion, metadataUrl: opts?.resourceMetadataUrl }); if (!response || response.status === 404) { - throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); + // Per SEP-985, protected resource metadata is optional + return undefined; } if (!response.ok) { From 96e9656e97fd202097aa7169517c0a9c93b18648 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 25 Oct 2025 22:34:33 +0200 Subject: [PATCH 2/4] Fix linting error --- src/client/auth.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index f0b876361..519829559 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -2380,7 +2380,9 @@ describe('OAuth Authorization', () => { expect(result).toBe('REDIRECT'); // Verify we tried PRM discovery (with retry for CORS) - expect(mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')).length).toBeGreaterThan(0); + expect(mockFetch.mock.calls.filter(call => call[0].toString().includes('oauth-protected-resource')).length).toBeGreaterThan( + 0 + ); // Verify we eventually fell back to auth server metadata expect(mockFetch.mock.calls.some(call => call[0].toString().includes('oauth-authorization-server'))).toBe(true); From c0fbfd0310779ecdb3af6b8c8b7f738aa7c2c4bd Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 25 Oct 2025 23:07:08 +0200 Subject: [PATCH 3/4] fix: respect scope from WWW-Authenticate header and Protected Resource Metadata Implements proper scope selection priority per MCP OAuth spec: 1. Explicit scope parameter (user override) 2. WWW-Authenticate challenge scope (authoritative per spec) 3. Protected Resource Metadata scopes_supported 4. Client default scope Changes: - Add extractChallengeScope() to parse scope from WWW-Authenticate header - Add selectScopes() helper with priority logic - Update auth(), authInternal() to accept and use challengeScope - Update SSE, StreamableHTTP, and middleware transports to extract and pass challenge scope - Add comprehensive tests for extractChallengeScope() and scope selection priority All 869 tests passing. --- src/client/auth.test.ts | 380 +++++++++++++++++++++++++++++++++++ src/client/auth.ts | 108 +++++++++- src/client/middleware.ts | 4 +- src/client/sse.ts | 6 +- src/client/streamableHttp.ts | 5 +- 5 files changed, 499 insertions(+), 4 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 519829559..5c54ad0c6 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -9,6 +9,7 @@ import { registerClient, discoverOAuthProtectedResourceMetadata, extractResourceMetadataUrl, + extractChallengeScope, auth, type OAuthClientProvider } from './auth.js'; @@ -69,6 +70,83 @@ describe('OAuth Authorization', () => { }); }); + describe('extractChallengeScope', () => { + it('extracts scope from WWW-Authenticate header', () => { + const mockResponse = { + headers: { + get: jest.fn(name => (name === 'WWW-Authenticate' ? 'Bearer realm="mcp", scope="read write"' : null)) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBe('read write'); + }); + + it('extracts scope when combined with resource_metadata', () => { + const mockResponse = { + headers: { + get: jest.fn( + name => + name === 'WWW-Authenticate' + ? 'Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="api:read api:write"' + : null + ) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBe('api:read api:write'); + }); + + it('returns undefined when no WWW-Authenticate header present', () => { + const mockResponse = { + headers: { + get: jest.fn(() => null) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBeUndefined(); + }); + + it('returns undefined when scope not in header', () => { + const mockResponse = { + headers: { + get: jest.fn(name => (name === 'WWW-Authenticate' ? 'Bearer realm="mcp"' : null)) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBeUndefined(); + }); + + it('handles empty scope string', () => { + const mockResponse = { + headers: { + get: jest.fn(name => (name === 'WWW-Authenticate' ? 'Bearer realm="mcp", scope=""' : null)) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBeUndefined(); + }); + + it('returns undefined for non-Bearer authentication', () => { + const mockResponse = { + headers: { + get: jest.fn(name => (name === 'WWW-Authenticate' ? 'Basic realm="mcp", scope="read"' : null)) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBeUndefined(); + }); + + it('handles single scope value', () => { + const mockResponse = { + headers: { + get: jest.fn(name => (name === 'WWW-Authenticate' ? 'Bearer realm="mcp", scope="mcp"' : null)) + } + } as unknown as Response; + + expect(extractChallengeScope(mockResponse)).toBe('mcp'); + }); + }); + describe('discoverOAuthProtectedResourceMetadata', () => { const validMetadata = { resource: 'https://resource.example.com', @@ -2388,6 +2466,308 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls.some(call => call[0].toString().includes('oauth-authorization-server'))).toBe(true); }); }); + + describe('Scope selection priority', () => { + beforeEach(() => { + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + }); + + it('uses explicit scope when provided', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['prm:read', 'prm:write'] + }) + }); + } else if (urlString.includes('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 }); + }); + + const customProvider = { + ...mockProvider, + clientMetadata: { + ...mockProvider.clientMetadata, + scope: 'client:default' + } + }; + + const result = await auth(customProvider, { + serverUrl: 'https://resource.example.com', + scope: 'explicit:scope', + challengeScope: 'challenge:scope' + }); + + expect(result).toBe('REDIRECT'); + + // Verify explicit scope was used in authorization URL + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('explicit:scope'); + }); + + it('uses challenge scope when no explicit scope provided', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['prm:read', 'prm:write'] + }) + }); + } else if (urlString.includes('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 }); + }); + + const customProvider = { + ...mockProvider, + clientMetadata: { + ...mockProvider.clientMetadata, + scope: 'client:default' + } + }; + + const result = await auth(customProvider, { + serverUrl: 'https://resource.example.com', + challengeScope: 'challenge:scope' + }); + + expect(result).toBe('REDIRECT'); + + // Verify challenge scope was used + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('challenge:scope'); + }); + + it('uses PRM scopes_supported when no explicit or challenge scope', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['prm:read', 'prm:write'] + }) + }); + } else if (urlString.includes('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 }); + }); + + const customProvider = { + ...mockProvider, + clientMetadata: { + ...mockProvider.clientMetadata, + scope: 'client:default' + } + }; + + const result = await auth(customProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify PRM scopes were joined with spaces and used + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('prm:read prm:write'); + }); + + it('uses client default scope when no other sources available', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + // No scopes_supported + }) + }); + } else if (urlString.includes('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 }); + }); + + const customProvider = { + ...mockProvider, + clientMetadata: { + ...mockProvider.clientMetadata, + scope: 'client:default' + } + }; + + const result = await auth(customProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify client default scope was used + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('client:default'); + }); + + it('proceeds without scope when no sources provide one', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('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 }); + }); + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify authorization called without scope (no scope parameter in URL) + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBeNull(); + }); + + it('properly joins multiple scopes_supported values with spaces', async () => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString === 'https://resource.example.com/.well-known/oauth-protected-resource') { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://resource.example.com', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['scope1', 'scope2', 'scope3'] + }) + }); + } else if (urlString.includes('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 }); + }); + + const result = await auth(mockProvider, { + serverUrl: 'https://resource.example.com' + }); + + expect(result).toBe('REDIRECT'); + + // Verify all scopes joined with spaces + const redirectCall = (mockProvider.redirectToAuthorization as jest.Mock).mock.calls[0]; + const authUrl: URL = redirectCall[0]; + expect(authUrl.searchParams.get('scope')).toBe('scope1 scope2 scope3'); + }); + }); }); describe('exchangeAuthorization with multiple client authentication methods', () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index b6534f648..d9e8517dd 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -282,6 +282,57 @@ export async function parseErrorResponse(input: Response | string): Promise 0) { + return resourceMetadata.scopes_supported.join(' '); + } + + // Priority 4: Client default + return clientDefaultScope; +} + /** * Orchestrates the full auth flow with a server. * @@ -294,6 +345,7 @@ export async function auth( serverUrl: string | URL; authorizationCode?: string; scope?: string; + challengeScope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; } @@ -321,12 +373,14 @@ async function authInternal( serverUrl, authorizationCode, scope, + challengeScope, resourceMetadataUrl, fetchFn }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; + challengeScope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; } @@ -426,13 +480,21 @@ async function authInternal( const state = provider.state ? await provider.state() : undefined; + // Select scopes based on priority: explicit > challenge > PRM > client default + const selectedScope = selectScopes({ + explicitScope: scope, + challengeScope, + resourceMetadata, + clientDefaultScope: provider.clientMetadata.scope + }); + // Start new authorization flow const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, state, redirectUrl: provider.redirectUrl, - scope: scope || provider.clientMetadata.scope, + scope: selectedScope, resource }); @@ -505,6 +567,50 @@ export function extractResourceMetadataUrl(res: Response): URL | undefined { } } +/** + * Extracts scope from WWW-Authenticate response header. + * + * Per RFC 6750 Section 3 and MCP specification, servers SHOULD include a scope + * parameter in the WWW-Authenticate header when returning 401 responses to indicate + * the scopes required for accessing the resource. + * + * The scope parameter in the challenge is authoritative for the current request and + * takes precedence over scopes advertised in Protected Resource Metadata. + * + * Expected header format: + * ``` + * WWW-Authenticate: Bearer scope="files:read files:write" + * ``` + * + * Or combined with resource_metadata: + * ``` + * WWW-Authenticate: Bearer resource_metadata="...", scope="files:read" + * ``` + * + * @param res - HTTP Response object to extract the header from + * @returns The scope string if present (space-separated scopes), undefined otherwise + */ +export function extractChallengeScope(res: Response): string | undefined { + const authenticateHeader = res.headers.get('WWW-Authenticate'); + if (!authenticateHeader) { + return undefined; + } + + const [type, scheme] = authenticateHeader.split(' '); + if (type.toLowerCase() !== 'bearer' || !scheme) { + return undefined; + } + + const regex = /scope="([^"]*)"/; + const match = regex.exec(authenticateHeader); + + if (!match) { + return undefined; + } + + return match[1] || undefined; +} + /** * Looks up RFC 9728 OAuth 2.0 Protected Resource Metadata. * diff --git a/src/client/middleware.ts b/src/client/middleware.ts index a7cbc6c69..f35f4e8a5 100644 --- a/src/client/middleware.ts +++ b/src/client/middleware.ts @@ -1,4 +1,4 @@ -import { auth, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { auth, extractResourceMetadataUrl, extractChallengeScope, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { FetchLike } from '../shared/transport.js'; /** @@ -55,6 +55,7 @@ export const withOAuth = if (response.status === 401) { try { const resourceMetadataUrl = extractResourceMetadataUrl(response); + const challengeScope = extractChallengeScope(response); // Use provided baseUrl or extract from request URL const serverUrl = baseUrl || (typeof input === 'string' ? new URL(input).origin : input.origin); @@ -62,6 +63,7 @@ export const withOAuth = const result = await auth(provider, { serverUrl, resourceMetadataUrl, + challengeScope, fetchFn: next }); diff --git a/src/client/sse.ts b/src/client/sse.ts index aa4942444..8c3a8dff6 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -1,7 +1,7 @@ import { EventSource, type ErrorEvent, type EventSourceInit } from 'eventsource'; import { Transport, FetchLike } from '../shared/transport.js'; import { JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; -import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { auth, AuthResult, extractResourceMetadataUrl, extractChallengeScope, OAuthClientProvider, UnauthorizedError } from './auth.js'; export class SseError extends Error { constructor( @@ -64,6 +64,7 @@ export class SSEClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; private _resourceMetadataUrl?: URL; + private _challengeScope?: string; private _eventSourceInit?: EventSourceInit; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; @@ -137,6 +138,7 @@ export class SSEClientTransport implements Transport { if (response.status === 401 && response.headers.has('www-authenticate')) { this._resourceMetadataUrl = extractResourceMetadataUrl(response); + this._challengeScope = extractChallengeScope(response); } return response; @@ -246,10 +248,12 @@ export class SSEClientTransport implements Transport { if (!response.ok) { if (response.status === 401 && this._authProvider) { this._resourceMetadataUrl = extractResourceMetadataUrl(response); + this._challengeScope = extractChallengeScope(response); const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, + challengeScope: this._challengeScope, fetchFn: this._fetch }); if (result !== 'AUTHORIZED') { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 12cb94864..5726c42e3 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -1,6 +1,6 @@ import { Transport, FetchLike } from '../shared/transport.js'; import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResponse, JSONRPCMessage, JSONRPCMessageSchema } from '../types.js'; -import { auth, AuthResult, extractResourceMetadataUrl, OAuthClientProvider, UnauthorizedError } from './auth.js'; +import { auth, AuthResult, extractResourceMetadataUrl, extractChallengeScope, OAuthClientProvider, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections @@ -125,6 +125,7 @@ export class StreamableHTTPClientTransport implements Transport { private _abortController?: AbortController; private _url: URL; private _resourceMetadataUrl?: URL; + private _challengeScope?: string; private _requestInit?: RequestInit; private _authProvider?: OAuthClientProvider; private _fetch?: FetchLike; @@ -438,10 +439,12 @@ export class StreamableHTTPClientTransport implements Transport { } this._resourceMetadataUrl = extractResourceMetadataUrl(response); + this._challengeScope = extractChallengeScope(response); const result = await auth(this._authProvider, { serverUrl: this._url, resourceMetadataUrl: this._resourceMetadataUrl, + challengeScope: this._challengeScope, fetchFn: this._fetch }); if (result !== 'AUTHORIZED') { From e20bc83ad43036b6134674edee1f7d166c8cb9d6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sat, 25 Oct 2025 23:09:42 +0200 Subject: [PATCH 4/4] Fix linting error --- src/client/auth.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 5c54ad0c6..a1474611c 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -84,11 +84,10 @@ describe('OAuth Authorization', () => { it('extracts scope when combined with resource_metadata', () => { const mockResponse = { headers: { - get: jest.fn( - name => - name === 'WWW-Authenticate' - ? 'Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="api:read api:write"' - : null + get: jest.fn(name => + name === 'WWW-Authenticate' + ? 'Bearer realm="mcp", resource_metadata="https://example.com/.well-known/oauth-protected-resource", scope="api:read api:write"' + : null ) } } as unknown as Response;