Skip to content

Returning undefined from discoverOAuthMetadata for CORS errors #717

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,19 @@ describe("OAuth Authorization", () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
});

it("returns undefined when both CORS requests fail in fetchWithCorsRetry", async () => {
// fetchWithCorsRetry tries with headers (fails with CORS), then retries without headers (also fails with CORS)
// simulating a 404 w/o headers set. We want this to return undefined, not throw TypeError
mockFetch.mockImplementation(() => {
// Both the initial request with headers and retry without headers fail with CORS TypeError
return Promise.reject(new TypeError("Failed to fetch"));
});

// This should return undefined (the desired behavior after the fix)
const metadata = await discoverOAuthMetadata("https://auth.example.com/path");
expect(metadata).toBeUndefined();
});

it("returns undefined when discovery endpoint returns 404", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down
47 changes: 22 additions & 25 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,25 +292,24 @@ export async function discoverOAuthProtectedResourceMetadata(
return OAuthProtectedResourceMetadataSchema.parse(await response.json());
}

/**
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
*
* 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> {
headers?: Record<string, string>,
): Promise<Response | undefined> {
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);
if (headers) {
// CORS errors come back as TypeError, retry without headers
return fetchWithCorsRetry(url)
} else {
// We're getting CORS errors on retry too, return undefined
return undefined
}
}
throw error;
}
Expand All @@ -334,7 +333,7 @@ function buildWellKnownPath(pathname: string): string {
async function tryMetadataDiscovery(
url: URL,
protocolVersion: string,
): Promise<Response> {
): Promise<Response | undefined> {
const headers = {
"MCP-Protocol-Version": protocolVersion
};
Expand All @@ -344,10 +343,16 @@ async function tryMetadataDiscovery(
/**
* Determines if fallback to root discovery should be attempted
*/
function shouldAttemptFallback(response: Response, pathname: string): boolean {
return response.status === 404 && pathname !== '/';
function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean {
return !response || response.status === 404 && pathname !== '/';
}

/**
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
*
* If the server returns a 404 for the well-known endpoint, this function will
* return `undefined`. Any other errors will be thrown as exceptions.
*/
export async function discoverOAuthMetadata(
authorizationServerUrl: string | URL,
opts?: { protocolVersion?: string },
Expand All @@ -362,18 +367,10 @@ export async function discoverOAuthMetadata(

// 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 {
// If fallback fails, return undefined
return undefined;
}
} else if (response.status === 404) {
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
}
if (!response || response.status === 404) {
return undefined;
}

Expand Down