import { createServer, ServerResponse, type IncomingMessage, type Server } from 'node:http'; import { JSONRPCMessage } from '../../src/types.js'; import { SSEClientTransport } from '../../src/client/sse.js'; import { OAuthClientProvider, UnauthorizedError } from '../../src/client/auth.js'; import { OAuthTokens } from '../../src/shared/auth.js'; import { InvalidClientError, InvalidGrantError, UnauthorizedClientError } from '../../src/server/auth/errors.js'; import { Mock, Mocked, MockedFunction, MockInstance } from 'vitest'; import { listenOnRandomPort } from '../helpers/http.js'; import { AddressInfo } from 'node:net'; /** * Parses HTTP Basic auth from a request's Authorization header. * Returns the decoded client_id and client_secret, or undefined if the header is absent or malformed. * client_secret_basic is the default client auth method when server metadata omits * token_endpoint_auth_methods_supported (RFC 8414 ยง2). */ function parseBasicAuth(req: IncomingMessage): { clientId: string; clientSecret: string } | undefined { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Basic ')) return undefined; const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); const sep = decoded.indexOf(':'); if (sep === -1) return undefined; return { clientId: decoded.slice(0, sep), clientSecret: decoded.slice(sep + 1) }; } describe('SSEClientTransport', () => { let resourceServer: Server; let authServer: Server; let transport: SSEClientTransport; let resourceBaseUrl: URL; let authBaseUrl: URL; let lastServerRequest: IncomingMessage; let sendServerMessage: ((message: string) => void) | null = null; beforeEach(async () => { // Reset state lastServerRequest = null as unknown as IncomingMessage; sendServerMessage = null; authServer = createServer((req, res) => { if (req.url === '/.well-known/oauth-authorization-server') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', registration_endpoint: 'https://auth.example.com/register', response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) ); return; } res.writeHead(401).end(); }); // Create a test server that will receive the EventSource connection resourceServer = createServer((req, res) => { lastServerRequest = req; // Send SSE headers res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); // Send the endpoint event res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); // Store reference to send function for tests sendServerMessage = (message: string) => { res.write(`data: ${message}\n\n`); }; // Handle request body for POST endpoints if (req.method === 'POST') { let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { (req as IncomingMessage & { body: string }).body = body; res.end(); }); } }); // Start server on random port await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(async () => { await transport.close(); await resourceServer.close(); await authServer.close(); vi.clearAllMocks(); }); describe('connection handling', () => { it('establishes SSE connection and receives endpoint', async () => { transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); expect(lastServerRequest.headers.accept).toBe('text/event-stream'); expect(lastServerRequest.method).toBe('GET'); }); it('rejects if server returns non-200 status', async () => { // Create a server that returns 403 await resourceServer.close(); resourceServer = createServer((req, res) => { res.writeHead(403); res.end(); }); resourceBaseUrl = await listenOnRandomPort(resourceServer); transport = new SSEClientTransport(resourceBaseUrl); await expect(transport.start()).rejects.toThrow(); }); it('closes EventSource connection on close()', async () => { transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const closePromise = new Promise(resolve => { lastServerRequest.on('close', resolve); }); await transport.close(); await closePromise; }); }); describe('message handling', () => { it('receives and parses JSON-RPC messages', async () => { const receivedMessages: JSONRPCMessage[] = []; transport = new SSEClientTransport(resourceBaseUrl); transport.onmessage = msg => receivedMessages.push(msg); await transport.start(); const testMessage: JSONRPCMessage = { jsonrpc: '2.0', id: 'test-1', method: 'test', params: { foo: 'bar' } }; sendServerMessage!(JSON.stringify(testMessage)); // Wait for message processing await new Promise(resolve => setTimeout(resolve, 50)); expect(receivedMessages).toHaveLength(1); expect(receivedMessages[0]).toEqual(testMessage); }); it('handles malformed JSON messages', async () => { const errors: Error[] = []; transport = new SSEClientTransport(resourceBaseUrl); transport.onerror = err => errors.push(err); await transport.start(); sendServerMessage!('invalid json'); // Wait for message processing await new Promise(resolve => setTimeout(resolve, 50)); expect(errors).toHaveLength(1); expect(errors[0].message).toMatch(/JSON/); }); it('handles messages via POST requests', async () => { transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { jsonrpc: '2.0', id: 'test-1', method: 'test', params: { foo: 'bar' } }; await transport.send(testMessage); // Wait for request processing await new Promise(resolve => setTimeout(resolve, 50)); expect(lastServerRequest.method).toBe('POST'); expect(lastServerRequest.headers['content-type']).toBe('application/json'); expect(JSON.parse((lastServerRequest as IncomingMessage & { body: string }).body)).toEqual(testMessage); }); it('handles POST request failures', async () => { // Create a server that returns 500 for POST await resourceServer.close(); resourceServer = createServer((req, res) => { if (req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); } else { res.writeHead(500); res.end('Internal error'); } }); resourceBaseUrl = await listenOnRandomPort(resourceServer); transport = new SSEClientTransport(resourceBaseUrl); await transport.start(); const testMessage: JSONRPCMessage = { jsonrpc: '2.0', id: 'test-1', method: 'test', params: {} }; await expect(transport.send(testMessage)).rejects.toThrow(/500/); }); }); describe('header handling', () => { it('uses custom fetch implementation from EventSourceInit to add auth headers', async () => { const authToken = 'Bearer test-token'; // Create a fetch wrapper that adds auth header const fetchWithAuth = (url: string | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); headers.set('Authorization', authToken); return fetch(url.toString(), { ...init, headers }); }; transport = new SSEClientTransport(resourceBaseUrl, { eventSourceInit: { fetch: fetchWithAuth } }); await transport.start(); // Verify the auth header was received by the server expect(lastServerRequest.headers.authorization).toBe(authToken); }); it('uses custom fetch implementation from options', async () => { const authToken = 'Bearer custom-token'; const fetchWithAuth = vi.fn((url: string | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); headers.set('Authorization', authToken); return fetch(url.toString(), { ...init, headers }); }); transport = new SSEClientTransport(resourceBaseUrl, { fetch: fetchWithAuth }); await transport.start(); expect(lastServerRequest.headers.authorization).toBe(authToken); // Send a message to verify fetchWithAuth used for POST as well const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); expect(fetchWithAuth).toHaveBeenCalledTimes(2); expect(lastServerRequest.method).toBe('POST'); expect(lastServerRequest.headers.authorization).toBe(authToken); }); it('passes custom headers to fetch requests', async () => { const customHeaders = { Authorization: 'Bearer test-token', 'X-Custom-Header': 'custom-value' }; transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: customHeaders } }); await transport.start(); const originalFetch = global.fetch; try { global.fetch = vi.fn().mockResolvedValue({ ok: true }); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); expect(calledHeaders.get('content-type')).toBe('application/json'); customHeaders['X-Custom-Header'] = 'updated-value'; await transport.send(message); const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); } finally { global.fetch = originalFetch; } }); it('passes custom headers to fetch requests (Headers class)', async () => { const customHeaders = new Headers({ Authorization: 'Bearer test-token', 'X-Custom-Header': 'custom-value' }); transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: customHeaders } }); await transport.start(); const originalFetch = global.fetch; try { global.fetch = vi.fn().mockResolvedValue({ ok: true }); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); expect(calledHeaders.get('content-type')).toBe('application/json'); customHeaders.set('X-Custom-Header', 'updated-value'); await transport.send(message); const updatedHeaders = (global.fetch as Mock).mock.calls[1][1].headers; expect(updatedHeaders.get('X-Custom-Header')).toBe('updated-value'); } finally { global.fetch = originalFetch; } }); it('passes custom headers to fetch requests (array of tuples)', async () => { transport = new SSEClientTransport(resourceBaseUrl, { requestInit: { headers: [ ['Authorization', 'Bearer test-token'], ['X-Custom-Header', 'custom-value'] ] } }); await transport.start(); const originalFetch = global.fetch; try { global.fetch = vi.fn().mockResolvedValue({ ok: true }); await transport.send({ jsonrpc: '2.0', id: '1', method: 'test', params: {} }); const calledHeaders = (global.fetch as Mock).mock.calls[0][1].headers; expect(calledHeaders.get('Authorization')).toBe('Bearer test-token'); expect(calledHeaders.get('X-Custom-Header')).toBe('custom-value'); expect(calledHeaders.get('content-type')).toBe('application/json'); } finally { global.fetch = originalFetch; } }); }); describe('auth handling', () => { const authServerMetadataUrls = ['/.well-known/oauth-authorization-server', '/.well-known/openid-configuration']; let mockAuthProvider: Mocked; beforeEach(() => { mockAuthProvider = { get redirectUrl() { return 'http://localhost/callback'; }, get clientMetadata() { return { redirect_uris: ['http://localhost/callback'] }; }, clientInformation: vi.fn(() => ({ client_id: 'test-client-id', client_secret: 'test-client-secret' })), tokens: vi.fn(), saveTokens: vi.fn(), redirectToAuthorization: vi.fn(), saveCodeVerifier: vi.fn(), codeVerifier: vi.fn(), invalidateCredentials: vi.fn() }; }); it('attaches auth header from provider on SSE connection', async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: 'test-token', token_type: 'Bearer' }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await transport.start(); expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); expect(mockAuthProvider.tokens).toHaveBeenCalled(); }); it('attaches custom header from provider on initial SSE connection', async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: 'test-token', token_type: 'Bearer' }); const customHeaders = { 'X-Custom-Header': 'custom-value' }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, requestInit: { headers: customHeaders } }); await transport.start(); expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value'); expect(mockAuthProvider.tokens).toHaveBeenCalled(); }); it('attaches auth header from provider on POST requests', async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: 'test-token', token_type: 'Bearer' }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await transport.start(); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); expect(mockAuthProvider.tokens).toHaveBeenCalled(); }); it('attempts auth flow on 401 during SSE connection', async () => { // Create server that returns 401s resourceServer.close(); authServer.close(); // Start auth server on random port await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); resourceServer = createServer((req, res) => { lastServerRequest = req; if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`] }) ); return; } if (req.url !== '/') { res.writeHead(404).end(); } else { res.writeHead(401).end(); } }); resourceBaseUrl = await listenOnRandomPort(resourceServer); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await expect(() => transport.start()).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); it('attempts auth flow on 401 during POST request', async () => { // Create server that accepts SSE but returns 401 on POST resourceServer.close(); authServer.close(); await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); resourceServer = createServer((req, res) => { lastServerRequest = req; switch (req.method) { case 'GET': if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`] }) ); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case 'POST': res.writeHead(401); res.end(); break; } }); await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await transport.start(); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await expect(() => transport.send(message)).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization.mock.calls).toHaveLength(1); }); it('respects custom headers when using auth provider', async () => { mockAuthProvider.tokens.mockResolvedValue({ access_token: 'test-token', token_type: 'Bearer' }); const customHeaders = { 'X-Custom-Header': 'custom-value' }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, requestInit: { headers: customHeaders } }); await transport.start(); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); expect(lastServerRequest.headers.authorization).toBe('Bearer test-token'); expect(lastServerRequest.headers['x-custom-header']).toBe('custom-value'); }); it('refreshes expired token during SSE connection', async () => { // Mock tokens() to return expired token until saveTokens is called let currentTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }; mockAuthProvider.tokens.mockImplementation(() => currentTokens); mockAuthProvider.saveTokens.mockImplementation(tokens => { currentTokens = tokens; }); // Create server that returns 401 for expired token, then accepts new token resourceServer.close(); authServer.close(); authServer = createServer((req, res) => { if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const params = new URLSearchParams(body); const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && basicAuth?.clientId === 'test-client-id' && basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ access_token: 'new-token', token_type: 'Bearer', refresh_token: 'new-refresh-token' }) ); } else { res.writeHead(400).end(); } }); return; } res.writeHead(401).end(); }); // Start auth server on random port await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); let connectionAttempts = 0; resourceServer = createServer((req, res) => { lastServerRequest = req; if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`] }) ); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } const auth = req.headers.authorization; if (auth === 'Bearer expired-token') { res.writeHead(401).end(); return; } if (auth === 'Bearer new-token') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); connectionAttempts++; return; } res.writeHead(401).end(); }); await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await transport.start(); expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ access_token: 'new-token', token_type: 'Bearer', refresh_token: 'new-refresh-token' }); expect(connectionAttempts).toBe(1); expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); }); it('refreshes expired token during POST request', async () => { // Mock tokens() to return expired token until saveTokens is called let currentTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }; mockAuthProvider.tokens.mockImplementation(() => currentTokens); mockAuthProvider.saveTokens.mockImplementation(tokens => { currentTokens = tokens; }); // Create server that returns 401 for expired token, then accepts new token resourceServer.close(); authServer.close(); authServer = createServer((req, res) => { if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const params = new URLSearchParams(body); const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'refresh_token' && params.get('refresh_token') === 'refresh-token' && basicAuth?.clientId === 'test-client-id' && basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ access_token: 'new-token', token_type: 'Bearer', refresh_token: 'new-refresh-token' }) ); } else { res.writeHead(400).end(); } }); return; } res.writeHead(401).end(); }); // Start auth server on random port await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); let postAttempts = 0; resourceServer = createServer((req, res) => { lastServerRequest = req; if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`] }) ); return; } switch (req.method) { case 'GET': if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); break; case 'POST': { if (req.url !== '/') { res.writeHead(404).end(); return; } const auth = req.headers.authorization; if (auth === 'Bearer expired-token') { res.writeHead(401).end(); return; } if (auth === 'Bearer new-token') { res.writeHead(200).end(); postAttempts++; return; } res.writeHead(401).end(); break; } } }); await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await transport.start(); const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; await transport.send(message); expect(mockAuthProvider.saveTokens).toHaveBeenCalledWith({ access_token: 'new-token', token_type: 'Bearer', refresh_token: 'new-refresh-token' }); expect(postAttempts).toBe(1); expect(lastServerRequest.headers.authorization).toBe('Bearer new-token'); }); it('redirects to authorization if refresh token flow fails', async () => { // Mock tokens() to return expired token until saveTokens is called let currentTokens: OAuthTokens = { access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }; mockAuthProvider.tokens.mockImplementation(() => currentTokens); mockAuthProvider.saveTokens.mockImplementation(tokens => { currentTokens = tokens; }); // Create server that returns 401 for all tokens resourceServer.close(); authServer.close(); authServer = createServer((req, res) => { if (req.url && authServerMetadataUrls.includes(req.url)) { res.writeHead(404).end(); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request - always fail res.writeHead(400).end(); return; } res.writeHead(401).end(); }); // Start auth server on random port await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); resourceServer = createServer((req, res) => { lastServerRequest = req; if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }).end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [`${authBaseUrl}`] }) ); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(401).end(); }); await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider }); await expect(() => transport.start()).rejects.toThrow(UnauthorizedError); expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); }); it('invalidates all credentials on InvalidClientError during token refresh', async () => { // Mock tokens() to return token with refresh token mockAuthProvider.tokens.mockResolvedValue({ access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }); let baseUrl = resourceBaseUrl; // Create server that returns InvalidClientError on token refresh const server = createServer((req, res) => { lastServerRequest = req; // Handle OAuth metadata discovery if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ issuer: baseUrl.href, authorization_endpoint: `${baseUrl.href}authorize`, token_endpoint: `${baseUrl.href}token`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) ); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request - return InvalidClientError const error = new InvalidClientError('Client authentication failed'); res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject())); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(401).end(); }); baseUrl = await listenOnRandomPort(server); transport = new SSEClientTransport(baseUrl, { authProvider: mockAuthProvider }); await expect(() => transport.start()).rejects.toThrow(InvalidClientError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); }); it('invalidates all credentials on UnauthorizedClientError during token refresh', async () => { // Mock tokens() to return token with refresh token mockAuthProvider.tokens.mockResolvedValue({ access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }); let baseUrl = resourceBaseUrl; const server = createServer((req, res) => { lastServerRequest = req; // Handle OAuth metadata discovery if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ issuer: baseUrl.href, authorization_endpoint: `${baseUrl.href}authorize`, token_endpoint: `${baseUrl.href}token`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) ); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request - return UnauthorizedClientError const error = new UnauthorizedClientError('Client not authorized'); res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject())); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(401).end(); }); baseUrl = await listenOnRandomPort(server); transport = new SSEClientTransport(baseUrl, { authProvider: mockAuthProvider }); await expect(() => transport.start()).rejects.toThrow(UnauthorizedClientError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('all'); }); it('invalidates tokens on InvalidGrantError during token refresh', async () => { // Mock tokens() to return token with refresh token mockAuthProvider.tokens.mockResolvedValue({ access_token: 'expired-token', token_type: 'Bearer', refresh_token: 'refresh-token' }); let baseUrl = resourceBaseUrl; const server = createServer((req, res) => { lastServerRequest = req; // Handle OAuth metadata discovery if (req.url === '/.well-known/oauth-authorization-server' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ issuer: baseUrl.href, authorization_endpoint: `${baseUrl.href}authorize`, token_endpoint: `${baseUrl.href}token`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) ); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token refresh request - return InvalidGrantError const error = new InvalidGrantError('Invalid refresh token'); res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(error.toResponseObject())); return; } if (req.url !== '/') { res.writeHead(404).end(); return; } res.writeHead(401).end(); }); baseUrl = await listenOnRandomPort(server); transport = new SSEClientTransport(baseUrl, { authProvider: mockAuthProvider }); await expect(() => transport.start()).rejects.toThrow(InvalidGrantError); expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens'); }); }); describe('custom fetch in auth code paths', () => { let customFetch: MockedFunction; let globalFetchSpy: MockInstance; let mockAuthProvider: Mocked; let resourceServerHandler: Mock; /** * Helper function to create a mock auth provider with configurable behavior */ const createMockAuthProvider = ( config: { hasTokens?: boolean; tokensExpired?: boolean; hasRefreshToken?: boolean; clientRegistered?: boolean; authorizationCode?: string; } = {} ): Mocked => { const tokens = config.hasTokens ? { access_token: config.tokensExpired ? 'expired-token' : 'valid-token', token_type: 'Bearer' as const, ...(config.hasRefreshToken && { refresh_token: 'refresh-token' }) } : undefined; const clientInfo = config.clientRegistered ? { client_id: 'test-client-id', client_secret: 'test-client-secret' } : undefined; return { get redirectUrl() { return 'http://localhost/callback'; }, get clientMetadata() { return { redirect_uris: ['http://localhost/callback'], client_name: 'Test Client' }; }, clientInformation: vi.fn().mockResolvedValue(clientInfo), tokens: vi.fn().mockResolvedValue(tokens), saveTokens: vi.fn(), redirectToAuthorization: vi.fn(), saveCodeVerifier: vi.fn(), codeVerifier: vi.fn().mockResolvedValue('test-verifier'), invalidateCredentials: vi.fn() }; }; const createCustomFetchMockAuthServer = async () => { authServer = createServer((req, res) => { if (req.url === '/.well-known/oauth-authorization-server') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ issuer: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}`, authorization_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/authorize`, token_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/token`, registration_endpoint: `http://127.0.0.1:${(authServer.address() as AddressInfo).port}/register`, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] }) ); return; } if (req.url === '/token' && req.method === 'POST') { // Handle token exchange request let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { const params = new URLSearchParams(body); const basicAuth = parseBasicAuth(req); if ( params.get('grant_type') === 'authorization_code' && params.get('code') === 'test-auth-code' && basicAuth?.clientId === 'test-client-id' && basicAuth?.clientSecret === 'test-client-secret' ) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600, refresh_token: 'new-refresh-token' }) ); } else { res.writeHead(400).end(); } }); return; } res.writeHead(404).end(); }); // Start auth server on random port await new Promise(resolve => { authServer.listen(0, '127.0.0.1', () => { const addr = authServer.address() as AddressInfo; authBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); }; const createCustomFetchMockResourceServer = async () => { // Set up resource server that provides OAuth metadata resourceServer = createServer((req, res) => { lastServerRequest = req; if (req.url === '/.well-known/oauth-protected-resource') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ resource: resourceBaseUrl.href, authorization_servers: [authBaseUrl.href] }) ); return; } resourceServerHandler(req, res); }); // Start resource server on random port await new Promise(resolve => { resourceServer.listen(0, '127.0.0.1', () => { const addr = resourceServer.address() as AddressInfo; resourceBaseUrl = new URL(`http://127.0.0.1:${addr.port}`); resolve(); }); }); }; beforeEach(async () => { // Close existing servers to set up custom auth flow servers resourceServer.close(); authServer.close(); const originalFetch = fetch; // Create custom fetch spy that delegates to real fetch customFetch = vi.fn((url, init) => { return originalFetch(url.toString(), init); }); // Spy on global fetch to detect unauthorized usage globalFetchSpy = vi.spyOn(global, 'fetch'); // Create mock auth provider with default configuration mockAuthProvider = createMockAuthProvider({ hasTokens: false, clientRegistered: true }); // Set up auth server that handles OAuth discovery and token requests await createCustomFetchMockAuthServer(); // Set up resource server resourceServerHandler = vi.fn( ( _req: IncomingMessage, res: ServerResponse & { req: IncomingMessage; } ) => { res.writeHead(404).end(); } ); await createCustomFetchMockResourceServer(); }); afterEach(() => { globalFetchSpy.mockRestore(); }); it('uses custom fetch during auth flow on SSE connection 401 - no global fetch fallback', async () => { // Set up resource server that returns 401 on SSE connection and provides OAuth metadata resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { if (req.url === '/') { // Return 401 to trigger auth flow res.writeHead(401, { 'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` }); res.end(); return; } res.writeHead(404).end(); }); // Create transport with custom fetch and auth provider transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, fetch: customFetch }); // Attempt to start - should trigger auth flow and eventually fail with UnauthorizedError await expect(transport.start()).rejects.toThrow(UnauthorizedError); // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); // Verify specific OAuth endpoints were called with custom fetch const customFetchCalls = customFetch.mock.calls; const callUrls = customFetchCalls.map(([url]) => url.toString()); // Should have called resource metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); // Should have called OAuth authorization server metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); // Verify auth provider was called to redirect to authorization expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); }); it('uses custom fetch during auth flow on POST request 401 - no global fetch fallback', async () => { // Set up resource server that accepts SSE connection but returns 401 on POST resourceServerHandler.mockImplementation((req: IncomingMessage, res: ServerResponse) => { switch (req.method) { case 'GET': if (req.url === '/') { // Accept SSE connection res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}\n\n`); return; } break; case 'POST': if (req.url === '/') { // Return 401 to trigger auth retry res.writeHead(401, { 'WWW-Authenticate': `Bearer realm="mcp", resource_metadata="${resourceBaseUrl.href}.well-known/oauth-protected-resource"` }); res.end(); return; } break; } res.writeHead(404).end(); }); // Create transport with custom fetch and auth provider transport = new SSEClientTransport(resourceBaseUrl, { authProvider: mockAuthProvider, fetch: customFetch }); // Start the transport (should succeed) await transport.start(); // Send a message that should trigger 401 and auth retry const message: JSONRPCMessage = { jsonrpc: '2.0', id: '1', method: 'test', params: {} }; // Attempt to send message - should trigger auth flow and eventually fail await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); // Verify specific OAuth endpoints were called with custom fetch const customFetchCalls = customFetch.mock.calls; const callUrls = customFetchCalls.map(([url]) => url.toString()); // Should have called resource metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); // Should have called OAuth authorization server metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); // Should have attempted the POST request that triggered the 401 const postCalls = customFetchCalls.filter( ([url, options]) => url.toString() === resourceBaseUrl.href && options?.method === 'POST' ); expect(postCalls.length).toBeGreaterThan(0); // Verify auth provider was called to redirect to authorization expect(mockAuthProvider.redirectToAuthorization).toHaveBeenCalled(); // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); }); it('uses custom fetch in finishAuth method - no global fetch fallback', async () => { // Create mock auth provider that expects to save tokens const authProviderWithCode = createMockAuthProvider({ clientRegistered: true, authorizationCode: 'test-auth-code' }); // Create transport with custom fetch and auth provider transport = new SSEClientTransport(resourceBaseUrl, { authProvider: authProviderWithCode, fetch: customFetch }); // Call finishAuth with authorization code await transport.finishAuth('test-auth-code'); // Verify custom fetch was used expect(customFetch).toHaveBeenCalled(); // Verify specific OAuth endpoints were called with custom fetch const customFetchCalls = customFetch.mock.calls; const callUrls = customFetchCalls.map(([url]) => url.toString()); // Should have called resource metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-protected-resource'))).toBe(true); // Should have called OAuth authorization server metadata discovery expect(callUrls.some(url => url.includes('/.well-known/oauth-authorization-server'))).toBe(true); // Should have called token endpoint for authorization code exchange const tokenCalls = customFetchCalls.filter(([url, options]) => url.toString().includes('/token') && options?.method === 'POST'); expect(tokenCalls.length).toBeGreaterThan(0); // Verify tokens were saved expect(authProviderWithCode.saveTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600, refresh_token: 'new-refresh-token' }); // Global fetch should never have been called expect(globalFetchSpy).not.toHaveBeenCalled(); }); }); });