fix: consistently use consumer-provided fetch function (#767)
This commit is contained in:
parent
16ea27783c
commit
9d2a0aeb7b
1 changed files with 709 additions and 46 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { createServer, type IncomingMessage, type Server } from "http";
|
||||
import { createServer, ServerResponse, type IncomingMessage, type Server } from "http";
|
||||
import { AddressInfo } from "net";
|
||||
import { JSONRPCMessage } from "../types.js";
|
||||
import { SSEClientTransport } from "./sse.js";
|
||||
|
|
@ -1108,4 +1108,337 @@ describe("SSEClientTransport", () => {
|
|||
expect(mockAuthProvider.invalidateCredentials).toHaveBeenCalledWith('tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom fetch in auth code paths", () => {
|
||||
let customFetch: jest.MockedFunction<typeof fetch>;
|
||||
let globalFetchSpy: jest.SpyInstance;
|
||||
let mockAuthProvider: jest.Mocked<OAuthClientProvider>;
|
||||
let resourceServerHandler: jest.Mock<void, [IncomingMessage, ServerResponse<IncomingMessage> & {
|
||||
req: IncomingMessage;
|
||||
}], void>;
|
||||
|
||||
/**
|
||||
* Helper function to create a mock auth provider with configurable behavior
|
||||
*/
|
||||
const createMockAuthProvider = (config: {
|
||||
hasTokens?: boolean;
|
||||
tokensExpired?: boolean;
|
||||
hasRefreshToken?: boolean;
|
||||
clientRegistered?: boolean;
|
||||
authorizationCode?: string;
|
||||
} = {}): jest.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: jest.fn().mockResolvedValue(clientInfo),
|
||||
tokens: jest.fn().mockResolvedValue(tokens),
|
||||
saveTokens: jest.fn(),
|
||||
redirectToAuthorization: jest.fn(),
|
||||
saveCodeVerifier: jest.fn(),
|
||||
codeVerifier: jest.fn().mockResolvedValue("test-verifier"),
|
||||
invalidateCredentials: jest.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);
|
||||
if (params.get("grant_type") === "authorization_code" &&
|
||||
params.get("code") === "test-auth-code" &&
|
||||
params.get("client_id") === "test-client-id") {
|
||||
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 = jest.fn((url, init) => {
|
||||
return originalFetch(url.toString(), init);
|
||||
});
|
||||
|
||||
// Spy on global fetch to detect unauthorized usage
|
||||
globalFetchSpy = jest.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 = jest.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, res) => {
|
||||
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, res) => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue