fallback and refactor
This commit is contained in:
parent
72cb9a700f
commit
5eacdf174c
2 changed files with 188 additions and 43 deletions
|
|
@ -207,6 +207,144 @@ describe("OAuth Authorization", () => {
|
|||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path?param=value");
|
||||
});
|
||||
|
||||
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 discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource/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://resource.example.com/.well-known/oauth-protected-resource");
|
||||
expect(secondOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error 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,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path/name"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource");
|
||||
});
|
||||
|
||||
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 discoverOAuthProtectedResourceMetadata("https://resource.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://resource.example.com/.well-known/oauth-protected-resource");
|
||||
expect(lastOptions.headers).toEqual({
|
||||
"MCP-Protocol-Version": LATEST_PROTOCOL_VERSION
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fallback when resourceMetadataUrl is provided", async () => {
|
||||
// Call with explicit URL returns 404
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com/path", {
|
||||
resourceMetadataUrl: "https://custom.example.com/metadata"
|
||||
})).rejects.toThrow("Resource server does not implement OAuth 2.0 Protected Resource Metadata.");
|
||||
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1); // Should not attempt fallback when explicit URL is provided
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://custom.example.com/metadata");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverOAuthMetadata", () => {
|
||||
|
|
|
|||
|
|
@ -107,12 +107,13 @@ export async function auth(
|
|||
serverUrl: string | URL;
|
||||
authorizationCode?: string;
|
||||
scope?: string;
|
||||
resourceMetadataUrl?: URL }): Promise<AuthResult> {
|
||||
resourceMetadataUrl?: URL
|
||||
}): Promise<AuthResult> {
|
||||
|
||||
let resourceMetadata: OAuthProtectedResourceMetadata | undefined;
|
||||
let authorizationServerUrl = serverUrl;
|
||||
try {
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, {resourceMetadataUrl});
|
||||
resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { resourceMetadataUrl });
|
||||
if (resourceMetadata.authorization_servers && resourceMetadata.authorization_servers.length > 0) {
|
||||
authorizationServerUrl = resourceMetadata.authorization_servers[0];
|
||||
}
|
||||
|
|
@ -197,7 +198,7 @@ export async function auth(
|
|||
return "REDIRECT";
|
||||
}
|
||||
|
||||
export async function selectResourceURL(serverUrl: string| URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
|
||||
export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise<URL | undefined> {
|
||||
const defaultResource = resourceUrlFromServerUrl(serverUrl);
|
||||
|
||||
// If provider has custom validation, delegate to it
|
||||
|
|
@ -256,34 +257,16 @@ export async function discoverOAuthProtectedResourceMetadata(
|
|||
serverUrl: string | URL,
|
||||
opts?: { protocolVersion?: string, resourceMetadataUrl?: string | URL },
|
||||
): Promise<OAuthProtectedResourceMetadata> {
|
||||
const response = await discoverMetadataWithFallback(
|
||||
serverUrl,
|
||||
'oauth-protected-resource',
|
||||
{
|
||||
protocolVersion: opts?.protocolVersion,
|
||||
metadataUrl: opts?.resourceMetadataUrl,
|
||||
},
|
||||
);
|
||||
|
||||
let url: URL
|
||||
if (opts?.resourceMetadataUrl) {
|
||||
url = new URL(opts?.resourceMetadataUrl);
|
||||
} else {
|
||||
const issuer = new URL(serverUrl);
|
||||
const wellKnownPath = buildWellKnownPath('oauth-protected-resource', issuer.pathname);
|
||||
url = new URL(wellKnownPath, issuer);
|
||||
url.search = issuer.search;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
"MCP-Protocol-Version": opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// CORS errors come back as TypeError
|
||||
if (error instanceof TypeError) {
|
||||
response = await fetch(url);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
if (!response || response.status === 404) {
|
||||
throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`);
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +333,38 @@ function shouldAttemptFallback(response: Response | undefined, pathname: string)
|
|||
return !response || response.status === 404 && pathname !== '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function for discovering OAuth metadata with fallback support
|
||||
*/
|
||||
async function discoverMetadataWithFallback(
|
||||
serverUrl: string | URL,
|
||||
wellKnownType: 'oauth-authorization-server' | 'oauth-protected-resource',
|
||||
opts?: { protocolVersion?: string; metadataUrl?: string | URL },
|
||||
): Promise<Response | undefined> {
|
||||
const issuer = new URL(serverUrl);
|
||||
const protocolVersion = opts?.protocolVersion ?? LATEST_PROTOCOL_VERSION;
|
||||
|
||||
let url: URL;
|
||||
if (opts?.metadataUrl) {
|
||||
url = new URL(opts.metadataUrl);
|
||||
} else {
|
||||
// Try path-aware discovery first
|
||||
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
|
||||
url = new URL(wellKnownPath, issuer);
|
||||
url.search = issuer.search;
|
||||
}
|
||||
|
||||
let response = await tryMetadataDiscovery(url, protocolVersion);
|
||||
|
||||
// If path-aware discovery fails with 404 and we're not already at root, try fallback to root discovery
|
||||
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) {
|
||||
const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer);
|
||||
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata.
|
||||
*
|
||||
|
|
@ -360,20 +375,12 @@ 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;
|
||||
const response = await discoverMetadataWithFallback(
|
||||
authorizationServerUrl,
|
||||
'oauth-authorization-server',
|
||||
opts,
|
||||
);
|
||||
|
||||
// Try path-aware discovery first (RFC 8414 compliant)
|
||||
const wellKnownPath = buildWellKnownPath('oauth-authorization-server', issuer.pathname);
|
||||
const pathAwareUrl = new URL(wellKnownPath, issuer);
|
||||
pathAwareUrl.search = issuer.search;
|
||||
let response = await tryMetadataDiscovery(pathAwareUrl, protocolVersion);
|
||||
|
||||
// If path-aware discovery fails with 404, try fallback to root discovery
|
||||
if (shouldAttemptFallback(response, issuer.pathname)) {
|
||||
const rootUrl = new URL("/.well-known/oauth-authorization-server", issuer);
|
||||
response = await tryMetadataDiscovery(rootUrl, protocolVersion);
|
||||
}
|
||||
if (!response || response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue