This commit is contained in:
ihrpr 2025-06-24 11:38:55 +01:00
commit 03da7cfc66
No known key found for this signature in database
2 changed files with 187 additions and 21 deletions

View file

@ -225,6 +225,126 @@ describe("OAuth Authorization", () => {
});
});
it("falls back to root discovery when path-aware discovery returns 404", async () => {
// First call (path-aware) returns 404
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
// Second call (root fallback) succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata,
});
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(2);
// First call should be path-aware
const [firstUrl, firstOptions] = calls[0];
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
expect(firstOptions.headers).toEqual({
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
});
// Second call should be root fallback
const [secondUrl, secondOptions] = calls[1];
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
expect(secondOptions.headers).toEqual({
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
});
});
it("returns undefined when both path-aware and root discovery return 404", async () => {
// First call (path-aware) returns 404
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
// Second call (root fallback) also returns 404
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
expect(metadata).toBeUndefined();
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(2);
});
it("does not fallback when the original URL is already at root path", async () => {
// First call (path-aware for root) returns 404
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
expect(metadata).toBeUndefined();
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1); // Should not attempt fallback
const [url] = calls[0];
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
});
it("does not fallback when the original URL has no path", async () => {
// First call (path-aware for no path) returns 404
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const metadata = await discoverOAuthMetadata("https://auth.example.com");
expect(metadata).toBeUndefined();
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(1); // Should not attempt fallback
const [url] = calls[0];
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
});
it("falls back when path-aware discovery encounters CORS error", async () => {
// First call (path-aware) fails with TypeError (CORS)
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
// Retry path-aware without headers (simulating CORS retry)
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
// Second call (root fallback) succeeds
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => validMetadata,
});
const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path");
expect(metadata).toEqual(validMetadata);
const calls = mockFetch.mock.calls;
expect(calls.length).toBe(3);
// Final call should be root fallback
const [lastUrl, lastOptions] = calls[2];
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
expect(lastOptions.headers).toEqual({
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
});
});
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
// Set up a counter to control behavior
let callCount = 0;

View file

@ -293,36 +293,82 @@ export async function discoverOAuthProtectedResourceMetadata(
* If the server returns a 404 for the well-known endpoint, this function will
* return `undefined`. Any other errors will be thrown as exceptions.
*/
/**
* Helper function to handle fetch with CORS retry logic
*/
async function fetchWithCorsRetry(
url: URL,
headers: Record<string, string>,
): Promise<Response> {
try {
return await fetch(url, { headers });
} catch (error) {
// CORS errors come back as TypeError, retry without headers
if (error instanceof TypeError) {
return await fetch(url);
}
throw error;
}
}
/**
* Constructs the well-known path for OAuth metadata discovery
*/
function buildWellKnownPath(pathname: string): string {
let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`;
if (pathname.endsWith('/')) {
// Strip trailing slash from pathname to avoid double slashes
wellKnownPath = wellKnownPath.slice(0, -1);
}
return wellKnownPath;
}
/**
* Tries to discover OAuth metadata at a specific URL
*/
async function tryMetadataDiscovery(
url: URL,
protocolVersion: string,
): Promise<Response> {
const headers = {
"MCP-Protocol-Version": protocolVersion
};
return await fetchWithCorsRetry(url, headers);
}
/**
* Determines if fallback to root discovery should be attempted
*/
function shouldAttemptFallback(response: Response, pathname: string): boolean {
return response.status === 404 && pathname !== '/';
}
export async function discoverOAuthMetadata(
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
): Promise<OAuthMetadata | undefined> {
const issuer = new URL(authorizationServerUrl);
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`;
if (issuer.pathname.endsWith('/')) {
// Strip trailing slash from pathname
wellKnownPath = wellKnownPath.slice(0, -1);
}
const url = new URL(wellKnownPath, issuer);
// Try path-aware discovery first (RFC 8414 compliant)
const wellKnownPath = buildWellKnownPath(issuer.pathname);
const pathAwareUrl = new URL(wellKnownPath, issuer);
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
let response: Response;
try {
response = await fetch(url, {
headers: {
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
// If path-aware discovery fails with 404, try fallback to root discovery
if (shouldAttemptFallback(response, issuer.pathname)) {
try {
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
if (response.status === 404) {
return undefined;
}
});
} catch (error) {
// CORS errors come back as TypeError
if (error instanceof TypeError) {
response = await fetch(url);
} else {
throw error;
} catch {
// If fallback fails, return undefined
return undefined;
}
}
if (response.status === 404) {
} else if (response.status === 404) {
return undefined;
}