Co-authored-by: Basil Hosmer <basil@anthropic.com> Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
1525 lines
59 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|