Skip to content

Commit ebf8e2d

Browse files
authored
Merge pull request #692 from modelcontextprotocol/ihrpr/fallback-for-auth-well-known
Fallback for` /.well-known/oauth-authorization-server` dropping path
2 parents 0fc9bd0 + 03da7cf commit ebf8e2d

File tree

2 files changed

+187
-21
lines changed

2 files changed

+187
-21
lines changed

src/client/auth.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,126 @@ describe("OAuth Authorization", () => {
225225
});
226226
});
227227

228+
it("falls back to root discovery when path-aware discovery returns 404", async () => {
229+
// First call (path-aware) returns 404
230+
mockFetch.mockResolvedValueOnce({
231+
ok: false,
232+
status: 404,
233+
});
234+
235+
// Second call (root fallback) succeeds
236+
mockFetch.mockResolvedValueOnce({
237+
ok: true,
238+
status: 200,
239+
json: async () => validMetadata,
240+
});
241+
242+
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
243+
expect(metadata).toEqual(validMetadata);
244+
245+
const calls = mockFetch.mock.calls;
246+
expect(calls.length).toBe(2);
247+
248+
// First call should be path-aware
249+
const [firstUrl, firstOptions] = calls[0];
250+
expect(firstUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server/path/name");
251+
expect(firstOptions.headers).toEqual({
252+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
253+
});
254+
255+
// Second call should be root fallback
256+
const [secondUrl, secondOptions] = calls[1];
257+
expect(secondUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
258+
expect(secondOptions.headers).toEqual({
259+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
260+
});
261+
});
262+
263+
it("returns undefined when both path-aware and root discovery return 404", async () => {
264+
// First call (path-aware) returns 404
265+
mockFetch.mockResolvedValueOnce({
266+
ok: false,
267+
status: 404,
268+
});
269+
270+
// Second call (root fallback) also returns 404
271+
mockFetch.mockResolvedValueOnce({
272+
ok: false,
273+
status: 404,
274+
});
275+
276+
const metadata = await discoverOAuthMetadata("https://auth.example.com/path/name");
277+
expect(metadata).toBeUndefined();
278+
279+
const calls = mockFetch.mock.calls;
280+
expect(calls.length).toBe(2);
281+
});
282+
283+
it("does not fallback when the original URL is already at root path", async () => {
284+
// First call (path-aware for root) returns 404
285+
mockFetch.mockResolvedValueOnce({
286+
ok: false,
287+
status: 404,
288+
});
289+
290+
const metadata = await discoverOAuthMetadata("https://auth.example.com/");
291+
expect(metadata).toBeUndefined();
292+
293+
const calls = mockFetch.mock.calls;
294+
expect(calls.length).toBe(1); // Should not attempt fallback
295+
296+
const [url] = calls[0];
297+
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
298+
});
299+
300+
it("does not fallback when the original URL has no path", async () => {
301+
// First call (path-aware for no path) returns 404
302+
mockFetch.mockResolvedValueOnce({
303+
ok: false,
304+
status: 404,
305+
});
306+
307+
const metadata = await discoverOAuthMetadata("https://auth.example.com");
308+
expect(metadata).toBeUndefined();
309+
310+
const calls = mockFetch.mock.calls;
311+
expect(calls.length).toBe(1); // Should not attempt fallback
312+
313+
const [url] = calls[0];
314+
expect(url.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
315+
});
316+
317+
it("falls back when path-aware discovery encounters CORS error", async () => {
318+
// First call (path-aware) fails with TypeError (CORS)
319+
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
320+
321+
// Retry path-aware without headers (simulating CORS retry)
322+
mockFetch.mockResolvedValueOnce({
323+
ok: false,
324+
status: 404,
325+
});
326+
327+
// Second call (root fallback) succeeds
328+
mockFetch.mockResolvedValueOnce({
329+
ok: true,
330+
status: 200,
331+
json: async () => validMetadata,
332+
});
333+
334+
const metadata = await discoverOAuthMetadata("https://auth.example.com/deep/path");
335+
expect(metadata).toEqual(validMetadata);
336+
337+
const calls = mockFetch.mock.calls;
338+
expect(calls.length).toBe(3);
339+
340+
// Final call should be root fallback
341+
const [lastUrl, lastOptions] = calls[2];
342+
expect(lastUrl.toString()).toBe("https://auth.example.com/.well-known/oauth-authorization-server");
343+
expect(lastOptions.headers).toEqual({
344+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
345+
});
346+
});
347+
228348
it("returns metadata when first fetch fails but second without MCP header succeeds", async () => {
229349
// Set up a counter to control behavior
230350
let callCount = 0;

src/client/auth.ts

Lines changed: 67 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -293,36 +293,82 @@ export async function discoverOAuthProtectedResourceMetadata(
293293
* If the server returns a 404 for the well-known endpoint, this function will
294294
* return `undefined`. Any other errors will be thrown as exceptions.
295295
*/
296+
/**
297+
* Helper function to handle fetch with CORS retry logic
298+
*/
299+
async function fetchWithCorsRetry(
300+
url: URL,
301+
headers: Record<string, string>,
302+
): Promise<Response> {
303+
try {
304+
return await fetch(url, { headers });
305+
} catch (error) {
306+
// CORS errors come back as TypeError, retry without headers
307+
if (error instanceof TypeError) {
308+
return await fetch(url);
309+
}
310+
throw error;
311+
}
312+
}
313+
314+
/**
315+
* Constructs the well-known path for OAuth metadata discovery
316+
*/
317+
function buildWellKnownPath(pathname: string): string {
318+
let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`;
319+
if (pathname.endsWith('/')) {
320+
// Strip trailing slash from pathname to avoid double slashes
321+
wellKnownPath = wellKnownPath.slice(0, -1);
322+
}
323+
return wellKnownPath;
324+
}
325+
326+
/**
327+
* Tries to discover OAuth metadata at a specific URL
328+
*/
329+
async function tryMetadataDiscovery(
330+
url: URL,
331+
protocolVersion: string,
332+
): Promise<Response> {
333+
const headers = {
334+
"MCP-Protocol-Version": protocolVersion
335+
};
336+
return await fetchWithCorsRetry(url, headers);
337+
}
338+
339+
/**
340+
* Determines if fallback to root discovery should be attempted
341+
*/
342+
function shouldAttemptFallback(response: Response, pathname: string): boolean {
343+
return response.status === 404 && pathname !== '/';
344+
}
345+
296346
export async function discoverOAuthMetadata(
297347
authorizationServerUrl: string | URL,
298348
opts?: { protocolVersion?: string },
299349
): Promise<OAuthMetadata | undefined> {
300350
const issuer = new URL(authorizationServerUrl);
351+
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
301352

302-
let wellKnownPath = `/.well-known/oauth-authorization-server${issuer.pathname}`;
303-
if (issuer.pathname.endsWith('/')) {
304-
// Strip trailing slash from pathname
305-
wellKnownPath = wellKnownPath.slice(0, -1);
306-
}
307-
const url = new URL(wellKnownPath, issuer);
353+
// Try path-aware discovery first (RFC 8414 compliant)
354+
const wellKnownPath = buildWellKnownPath(issuer.pathname);
355+
const pathAwareUrl = new URL(wellKnownPath, issuer);
356+
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
308357

309-
let response: Response;
310-
try {
311-
response = await fetch(url, {
312-
headers: {
313-
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
358+
// If path-aware discovery fails with 404, try fallback to root discovery
359+
if (shouldAttemptFallback(response, issuer.pathname)) {
360+
try {
361+
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
362+
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
363+
364+
if (response.status === 404) {
365+
return undefined;
314366
}
315-
});
316-
} catch (error) {
317-
// CORS errors come back as TypeError
318-
if (error instanceof TypeError) {
319-
response = await fetch(url);
320-
} else {
321-
throw error;
367+
} catch {
368+
// If fallback fails, return undefined
369+
return undefined;
322370
}
323-
}
324-
325-
if (response.status === 404) {
371+
} else if (response.status === 404) {
326372
return undefined;
327373
}
328374

0 commit comments

Comments
 (0)