typescript-sdk/test/client/sse.test.ts
Paul Carleton 4cbcec0edb
[v1.x backport] Default to client_secret_basic when server omits token_endpoint_auth_methods_supported (#1611)
Co-authored-by: Basil Hosmer <basil@anthropic.com>
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
2026-03-02 11:46:30 +00:00

1525 lines
59 KiB
TypeScript

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<void>(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<OAuthClientProvider>;
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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<void>(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<typeof fetch>;
let globalFetchSpy: MockInstance;
let mockAuthProvider: Mocked<OAuthClientProvider>;
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<OAuthClientProvider> => {
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<void>(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<void>(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<IncomingMessage> & {
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();
});
});
});