Skip to content

Fallback for /.well-known/oauth-authorization-server dropping path #692

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 1 commit into from
Jun 24, 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
120 changes: 120 additions & 0 deletions src/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
88 changes: 67 additions & 21 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down