Skip to content

Commit 5eacdf1

Browse files
committed
fallback and refactor
1 parent 72cb9a7 commit 5eacdf1

File tree

2 files changed

+188
-43
lines changed

2 files changed

+188
-43
lines changed

src/client/auth.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,144 @@ describe("OAuth Authorization", () => {
207207
const [url] = calls[0];
208208
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value");
209209
});
210+
211+
it("falls back to root discovery when path-aware discovery returns 404", async () => {
212+
// First call (path-aware) returns 404
213+
mockFetch.mockResolvedValueOnce({
214+
ok: false,
215+
status: 404,
216+
});
217+
218+
// Second call (root fallback) succeeds
219+
mockFetch.mockResolvedValueOnce({
220+
ok: true,
221+
status: 200,
222+
json: async () => validMetadata,
223+
});
224+
225+
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name");
226+
expect(metadata).toEqual(validMetadata);
227+
228+
const calls = mockFetch.mock.calls;
229+
expect(calls.length).toBe(2);
230+
231+
// First call should be path-aware
232+
const [firstUrl, firstOptions] = calls[0];
233+
expect(firstUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name");
234+
expect(firstOptions.headers).toEqual({
235+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
236+
});
237+
238+
// Second call should be root fallback
239+
const [secondUrl, secondOptions] = calls[1];
240+
expect(secondUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
241+
expect(secondOptions.headers).toEqual({
242+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
243+
});
244+
});
245+
246+
it("throws error when both path-aware and root discovery return 404", async () => {
247+
// First call (path-aware) returns 404
248+
mockFetch.mockResolvedValueOnce({
249+
ok: false,
250+
status: 404,
251+
});
252+
253+
// Second call (root fallback) also returns 404
254+
mockFetch.mockResolvedValueOnce({
255+
ok: false,
256+
status: 404,
257+
});
258+
259+
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name"))
260+
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
261+
262+
const calls = mockFetch.mock.calls;
263+
expect(calls.length).toBe(2);
264+
});
265+
266+
it("does not fallback when the original URL is already at root path", async () => {
267+
// First call (path-aware for root) returns 404
268+
mockFetch.mockResolvedValueOnce({
269+
ok: false,
270+
status: 404,
271+
});
272+
273+
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/"))
274+
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
275+
276+
const calls = mockFetch.mock.calls;
277+
expect(calls.length).toBe(1); // Should not attempt fallback
278+
279+
const [url] = calls[0];
280+
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
281+
});
282+
283+
it("does not fallback when the original URL has no path", async () => {
284+
// First call (path-aware for no path) returns 404
285+
mockFetch.mockResolvedValueOnce({
286+
ok: false,
287+
status: 404,
288+
});
289+
290+
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
291+
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
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://resource.example.com/.well-known/oauth-protected-resource");
298+
});
299+
300+
it("falls back when path-aware discovery encounters CORS error", async () => {
301+
// First call (path-aware) fails with TypeError (CORS)
302+
mockFetch.mockImplementationOnce(() => Promise.reject(new TypeError("CORS error")));
303+
304+
// Retry path-aware without headers (simulating CORS retry)
305+
mockFetch.mockResolvedValueOnce({
306+
ok: false,
307+
status: 404,
308+
});
309+
310+
// Second call (root fallback) succeeds
311+
mockFetch.mockResolvedValueOnce({
312+
ok: true,
313+
status: 200,
314+
json: async () => validMetadata,
315+
});
316+
317+
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/deep/path");
318+
expect(metadata).toEqual(validMetadata);
319+
320+
const calls = mockFetch.mock.calls;
321+
expect(calls.length).toBe(3);
322+
323+
// Final call should be root fallback
324+
const [lastUrl, lastOptions] = calls[2];
325+
expect(lastUrl.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
326+
expect(lastOptions.headers).toEqual({
327+
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
328+
});
329+
});
330+
331+
it("does not fallback when resourceMetadataUrl is provided", async () => {
332+
// Call with explicit URL returns 404
333+
mockFetch.mockResolvedValueOnce({
334+
ok: false,
335+
status: 404,
336+
});
337+
338+
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", {
339+
resourceMetadataUrl: "https://custom.example.com/metadata"
340+
})).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
341+
342+
const calls = mockFetch.mock.calls;
343+
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
344+
345+
const [url] = calls[0];
346+
expect(url.toString()).toBe("https://custom.example.com/metadata");
347+
});
210348
});
211349

212350
describe("discoverOAuthMetadata", () => {

src/client/auth.ts

Lines changed: 50 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,13 @@ export async function auth(
107107
serverUrl: string | URL;
108108
authorizationCode?: string;
109109
scope?: string;
110-
resourceMetadataUrl?: URL }): Promise<AuthResult> {
110+
resourceMetadataUrl?: URL
111+
}): Promise<AuthResult> {
111112

112113
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
113114
let authorizationServerUrl = serverUrl;
114115
try {
115-
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl});
116+
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl });
116117
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
117118
authorizationServerUrl = resourceMetadata.authorization_servers[0];
118119
}
@@ -197,7 +198,7 @@ export async function auth(
197198
return "REDIRECT";
198199
}
199200

200-
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
201+
export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
201202
const defaultResource = resourceUrlFromServerUrl(serverUrl);
202203

203204
// If provider has custom validation, delegate to it
@@ -256,34 +257,16 @@ export async function discoverOAuthProtectedResourceMetadata(
256257
serverUrl: string | URL,
257258
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
258259
): Promise<OAuthProtectedResourceMetadata> {
260+
const response = await discoverMetadataWithFallback(
261+
serverUrl,
262+
'oauth-protected-resource',
263+
{
264+
protocolVersion: opts?.protocolVersion,
265+
metadataUrl: opts?.resourceMetadataUrl,
266+
},
267+
);
259268

260-
let url: URL
261-
if (opts?.resourceMetadataUrl) {
262-
url = new URL(opts?.resourceMetadataUrl);
263-
} else {
264-
const issuer = new URL(serverUrl);
265-
const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname);
266-
url = new URL(wellKnownPath, issuer);
267-
url.search = issuer.search;
268-
}
269-
270-
let response: Response;
271-
try {
272-
response = await fetch(url, {
273-
headers: {
274-
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
275-
}
276-
});
277-
} catch (error) {
278-
// CORS errors come back as TypeError
279-
if (error instanceof TypeError) {
280-
response = await fetch(url);
281-
} else {
282-
throw error;
283-
}
284-
}
285-
286-
if (response.status === 404) {
269+
if (!response || response.status === 404) {
287270
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
288271
}
289272

@@ -350,6 +333,38 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string)
350333
return !response || response.status === 404 && pathname !== '/';
351334
}
352335

336+
/**
337+
* Generic function for discovering OAuth metadata with fallback support
338+
*/
339+
async function discoverMetadataWithFallback(
340+
serverUrl: string | URL,
341+
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
342+
opts?: { protocolVersion?: string; metadataUrl?: string | URL },
343+
): Promise<Response | undefined> {
344+
const issuer = new URL(serverUrl);
345+
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
346+
347+
let url: URL;
348+
if (opts?.metadataUrl) {
349+
url = new URL(opts.metadataUrl);
350+
} else {
351+
// Try path-aware discovery first
352+
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
353+
url = new URL(wellKnownPath, issuer);
354+
url.search = issuer.search;
355+
}
356+
357+
let response = await tryMetadataDiscovery(url, protocolVersion);
358+
359+
// If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
360+
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
361+
const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
362+
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
363+
}
364+
365+
return response;
366+
}
367+
353368
/**
354369
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
355370
*
@@ -360,20 +375,12 @@ export async function discoverOAuthMetadata(
360375
authorizationServerUrl: string | URL,
361376
opts?: { protocolVersion?: string },
362377
): Promise<OAuthMetadata | undefined> {
363-
const issuer = new URL(authorizationServerUrl);
364-
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
365-
366-
// Try path-aware discovery first (RFC 8414 compliant)
367-
const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname);
368-
const pathAwareUrl = new URL(wellKnownPath, issuer);
369-
pathAwareUrl.search = issuer.search;
370-
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
378+
const response = await discoverMetadataWithFallback(
379+
authorizationServerUrl,
380+
'oauth-authorization-server',
381+
opts,
382+
);
371383

372-
// If path-aware discovery fails with 404, try fallback to root discovery
373-
if (shouldAttemptFallback(response, issuer.pathname)) {
374-
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
375-
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
376-
}
377384
if (!response || response.status === 404) {
378385
return undefined;
379386
}

0 commit comments

Comments
 (0)