diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index aec5b7ff6..0e3a544a2 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -95,6 +95,16 @@ describe('OAuth Authorization', () => { expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ scope: scope }); }); + + it('returns error when present', async () => { + const mockResponse = { + headers: { + get: vi.fn(name => (name === 'WWW-Authenticate' ? `Bearer error="insufficient_scope", scope="admin"` : null)) + } + } as unknown as Response; + + expect(extractWWWAuthenticateParams(mockResponse)).toEqual({ error: 'insufficient_scope', scope: 'admin' }); + }); }); describe('discoverOAuthProtectedResourceMetadata', () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 105d3cad9..536ff6859 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -522,9 +522,9 @@ export async function selectResourceURL( } /** - * Extract resource_metadata and scope from WWW-Authenticate header. + * Extract resource_metadata, scope, and error from WWW-Authenticate header. */ -export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string } { +export function extractWWWAuthenticateParams(res: Response): { resourceMetadataUrl?: URL; scope?: string; error?: string } { const authenticateHeader = res.headers.get('WWW-Authenticate'); if (!authenticateHeader) { return {}; @@ -535,29 +535,51 @@ export function extractWWWAuthenticateParams(res: Response): { resourceMetadataU return {}; } - const resourceMetadataRegex = /resource_metadata="([^"]*)"/; - const resourceMetadataMatch = resourceMetadataRegex.exec(authenticateHeader); - - const scopeRegex = /scope="([^"]*)"/; - const scopeMatch = scopeRegex.exec(authenticateHeader); + const resourceMetadataMatch = extractFieldFromWwwAuth(res, 'resource_metadata') || undefined; let resourceMetadataUrl: URL | undefined; if (resourceMetadataMatch) { try { - resourceMetadataUrl = new URL(resourceMetadataMatch[1]); + resourceMetadataUrl = new URL(resourceMetadataMatch); } catch { // Ignore invalid URL } } - const scope = scopeMatch?.[1] || undefined; + const scope = extractFieldFromWwwAuth(res, 'scope') || undefined; + const error = extractFieldFromWwwAuth(res, 'error') || undefined; return { resourceMetadataUrl, - scope + scope, + error }; } +/** + * Extracts a specific field's value from the WWW-Authenticate header string. + * + * @param response The HTTP response object containing the headers. + * @param fieldName The name of the field to extract (e.g., "realm", "nonce"). + * @returns The field value + */ +function extractFieldFromWwwAuth(response: Response, fieldName: string): string | null { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + if (!wwwAuthHeader) { + return null; + } + + const pattern = new RegExp(`${fieldName}=(?:"([^"]+)"|([^\\s,]+))`); + const match = wwwAuthHeader.match(pattern); + + if (match) { + // Pattern matches: field_name="value" or field_name=value (unquoted) + return match[1] || match[2]; + } + + return null; +} + /** * Extract resource_metadata from response header. * @deprecated Use `extractWWWAuthenticateParams` instead. diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 12524fbcd..a4f582cfc 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -593,6 +593,98 @@ describe('StreamableHTTPClientTransport', () => { expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); + it('attempts upscoping on 403 with WWW-Authenticate header', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + const fetchMock = global.fetch as Mock; + fetchMock + // First call: returns 403 with insufficient_scope + .mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': + 'Bearer error="insufficient_scope", scope="new_scope", resource_metadata="http://example.com/resource"' + }), + text: () => Promise.resolve('Insufficient scope') + }) + // Second call: successful after upscoping + .mockResolvedValueOnce({ + ok: true, + status: 202, + headers: new Headers() + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('./auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + await transport.send(message); + + // Verify fetch was called twice + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Verify auth was called with the new scope + expect(authSpy).toHaveBeenCalledWith( + mockAuthProvider, + expect.objectContaining({ + scope: 'new_scope', + resourceMetadataUrl: new URL('http://example.com/resource') + }) + ); + + authSpy.mockRestore(); + }); + + it('prevents infinite upscoping on repeated 403', async () => { + const message: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'test', + params: {}, + id: 'test-id' + }; + + // Mock fetch calls to always return 403 with insufficient_scope + const fetchMock = global.fetch as Mock; + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + headers: new Headers({ + 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="new_scope"' + }), + text: () => Promise.resolve('Insufficient scope') + }); + + // Spy on the imported auth function and mock successful authorization + const authModule = await import('./auth.js'); + const authSpy = vi.spyOn(authModule, 'auth'); + authSpy.mockResolvedValue('AUTHORIZED'); + + // First send: should trigger upscoping + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(2); // Initial call + one retry after auth + expect(authSpy).toHaveBeenCalledTimes(1); // Auth called once + + // Second send: should fail immediately without re-calling auth + fetchMock.mockClear(); + authSpy.mockClear(); + await expect(transport.send(message)).rejects.toThrow('Server returned 403 after trying upscoping'); + + expect(fetchMock).toHaveBeenCalledTimes(1); // Only one fetch call + expect(authSpy).not.toHaveBeenCalled(); // Auth not called again + + authSpy.mockRestore(); + }); + describe('Reconnection Logic', () => { let transport: StreamableHTTPClientTransport; diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 508f8cef9..3ca50b954 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -134,6 +134,7 @@ export class StreamableHTTPClientTransport implements Transport { private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; private _hasCompletedAuthFlow = false; // Circuit breaker: detect auth success followed by immediate 401 + private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping. onclose?: () => void; onerror?: (error: Error) => void; @@ -452,12 +453,49 @@ export class StreamableHTTPClientTransport implements Transport { return this.send(message); } + if (response.status === 403 && this._authProvider) { + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); + + if (error === 'insufficient_scope') { + const wwwAuthHeader = response.headers.get('WWW-Authenticate'); + + // Check if we've already tried upscoping with this header to prevent infinite loops. + if (this._lastUpscopingHeader === wwwAuthHeader) { + throw new StreamableHTTPError(403, 'Server returned 403 after trying upscoping'); + } + + if (scope) { + this._scope = scope; + } + + if (resourceMetadataUrl) { + this._resourceMetadataUrl = resourceMetadataUrl; + } + + // Mark that upscoping was tried. + this._lastUpscopingHeader = wwwAuthHeader ?? undefined; + const result = await auth(this._authProvider, { + serverUrl: this._url, + resourceMetadataUrl: this._resourceMetadataUrl, + scope: this._scope, + fetchFn: this._fetch + }); + + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } + + return this.send(message); + } + } + const text = await response.text().catch(() => null); throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); } // Reset auth loop flag on successful response this._hasCompletedAuthFlow = false; + this._lastUpscopingHeader = undefined; // If the response is 202 Accepted, there's no body to process if (response.status === 202) {