Skip to content

Commit 9d2a0ae

Browse files
fix: consistently use consumer-provided fetch function (#767)
1 parent 16ea277 commit 9d2a0ae

File tree

7 files changed

+709
-46
lines changed

7 files changed

+709
-46
lines changed

src/client/auth.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,35 @@ describe("OAuth Authorization", () => {
347347
const [url] = calls[0];
348348
expect(url.toString()).toBe("https://custom.example.com/metadata");
349349
});
350+
351+
it("supports overriding the fetch function used for requests", async () => {
352+
const validMetadata = {
353+
resource: "https://resource.example.com",
354+
authorization_servers: ["https://auth.example.com"],
355+
};
356+
357+
const customFetch = jest.fn().mockResolvedValue({
358+
ok: true,
359+
status: 200,
360+
json: async () => validMetadata,
361+
});
362+
363+
const metadata = await discoverOAuthProtectedResourceMetadata(
364+
"https://resource.example.com",
365+
undefined,
366+
customFetch
367+
);
368+
369+
expect(metadata).toEqual(validMetadata);
370+
expect(customFetch).toHaveBeenCalledTimes(1);
371+
expect(mockFetch).not.toHaveBeenCalled();
372+
373+
const [url, options] = customFetch.mock.calls[0];
374+
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
375+
expect(options.headers).toEqual({
376+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
377+
});
378+
});
350379
});
351380

352381
describe("discoverOAuthMetadata", () => {
@@ -619,6 +648,39 @@ describe("OAuth Authorization", () => {
619648
discoverOAuthMetadata("https://auth.example.com")
620649
).rejects.toThrow();
621650
});
651+
652+
it("supports overriding the fetch function used for requests", async () => {
653+
const validMetadata = {
654+
issuer: "https://auth.example.com",
655+
authorization_endpoint: "https://auth.example.com/authorize",
656+
token_endpoint: "https://auth.example.com/token",
657+
registration_endpoint: "https://auth.example.com/register",
658+
response_types_supported: ["code"],
659+
code_challenge_methods_supported: ["S256"],
660+
};
661+
662+
const customFetch = jest.fn().mockResolvedValue({
663+
ok: true,
664+
status: 200,
665+
json: async () => validMetadata,
666+
});
667+
668+
const metadata = await discoverOAuthMetadata(
669+
"https://auth.example.com",
670+
{},
671+
customFetch
672+
);
673+
674+
expect(metadata).toEqual(validMetadata);
675+
expect(customFetch).toHaveBeenCalledTimes(1);
676+
expect(mockFetch).not.toHaveBeenCalled();
677+
678+
const [url, options] = customFetch.mock.calls[0];
679+
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
680+
expect(options.headers).toEqual({
681+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
682+
});
683+
});
622684
});
623685

624686
describe("startAuthorization", () => {
@@ -917,6 +979,46 @@ describe("OAuth Authorization", () => {
917979
})
918980
).rejects.toThrow("Token exchange failed");
919981
});
982+
983+
it("supports overriding the fetch function used for requests", async () => {
984+
const customFetch = jest.fn().mockResolvedValue({
985+
ok: true,
986+
status: 200,
987+
json: async () => validTokens,
988+
});
989+
990+
const tokens = await exchangeAuthorization("https://auth.example.com", {
991+
clientInformation: validClientInfo,
992+
authorizationCode: "code123",
993+
codeVerifier: "verifier123",
994+
redirectUri: "http://localhost:3000/callback",
995+
resource: new URL("https://api.example.com/mcp-server"),
996+
fetchFn: customFetch,
997+
});
998+
999+
expect(tokens).toEqual(validTokens);
1000+
expect(customFetch).toHaveBeenCalledTimes(1);
1001+
expect(mockFetch).not.toHaveBeenCalled();
1002+
1003+
const [url, options] = customFetch.mock.calls[0];
1004+
expect(url.toString()).toBe("https://auth.example.com/token");
1005+
expect(options).toEqual(
1006+
expect.objectContaining({
1007+
method: "POST",
1008+
headers: expect.any(Headers),
1009+
body: expect.any(URLSearchParams),
1010+
})
1011+
);
1012+
1013+
const body = options.body as URLSearchParams;
1014+
expect(body.get("grant_type")).toBe("authorization_code");
1015+
expect(body.get("code")).toBe("code123");
1016+
expect(body.get("code_verifier")).toBe("verifier123");
1017+
expect(body.get("client_id")).toBe("client123");
1018+
expect(body.get("client_secret")).toBe("secret123");
1019+
expect(body.get("redirect_uri")).toBe("http://localhost:3000/callback");
1020+
expect(body.get("resource")).toBe("https://api.example.com/mcp-server");
1021+
});
9201022
});
9211023

9221024
describe("refreshAuthorization", () => {
@@ -1824,6 +1926,68 @@ describe("OAuth Authorization", () => {
18241926
// Second call should be to AS metadata with the path from authorization server
18251927
expect(calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/oauth");
18261928
});
1929+
1930+
it("supports overriding the fetch function used for requests", async () => {
1931+
const customFetch = jest.fn();
1932+
1933+
// Mock PRM discovery
1934+
customFetch.mockResolvedValueOnce({
1935+
ok: true,
1936+
status: 200,
1937+
json: async () => ({
1938+
resource: "https://resource.example.com",
1939+
authorization_servers: ["https://auth.example.com"],
1940+
}),
1941+
});
1942+
1943+
// Mock AS metadata discovery
1944+
customFetch.mockResolvedValueOnce({
1945+
ok: true,
1946+
status: 200,
1947+
json: async () => ({
1948+
issuer: "https://auth.example.com",
1949+
authorization_endpoint: "https://auth.example.com/authorize",
1950+
token_endpoint: "https://auth.example.com/token",
1951+
registration_endpoint: "https://auth.example.com/register",
1952+
response_types_supported: ["code"],
1953+
code_challenge_methods_supported: ["S256"],
1954+
}),
1955+
});
1956+
1957+
const mockProvider: OAuthClientProvider = {
1958+
get redirectUrl() { return "http://localhost:3000/callback"; },
1959+
get clientMetadata() {
1960+
return {
1961+
client_name: "Test Client",
1962+
redirect_uris: ["http://localhost:3000/callback"],
1963+
};
1964+
},
1965+
clientInformation: jest.fn().mockResolvedValue({
1966+
client_id: "client123",
1967+
client_secret: "secret123",
1968+
}),
1969+
tokens: jest.fn().mockResolvedValue(undefined),
1970+
saveTokens: jest.fn(),
1971+
redirectToAuthorization: jest.fn(),
1972+
saveCodeVerifier: jest.fn(),
1973+
codeVerifier: jest.fn().mockResolvedValue("verifier123"),
1974+
};
1975+
1976+
const result = await auth(mockProvider, {
1977+
serverUrl: "https://resource.example.com",
1978+
fetchFn: customFetch,
1979+
});
1980+
1981+
expect(result).toBe("REDIRECT");
1982+
expect(customFetch).toHaveBeenCalledTimes(2);
1983+
expect(mockFetch).not.toHaveBeenCalled();
1984+
1985+
// Verify custom fetch was called for PRM discovery
1986+
expect(customFetch.mock.calls[0][0].toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
1987+
1988+
// Verify custom fetch was called for AS metadata discovery
1989+
expect(customFetch.mock.calls[1][0].toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
1990+
});
18271991
});
18281992

18291993
describe("exchangeAuthorization with multiple client authentication methods", () => {

0 commit comments

Comments
 (0)