Merge pull request #756 from modelcontextprotocol/patch-1
Fix oauth well-known paths to retain path and query
This commit is contained in:
commit
442e13b60e
2 changed files with 220 additions and 41 deletions
|
|
@ -178,6 +178,174 @@ describe("OAuth Authorization", () => {
|
|||
await expect(discoverOAuthProtectedResourceMetadata("https://resource.example.com"))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it("returns metadata when discovery succeeds with path", async () => {
|
||||
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(1);
|
||||
const [url] = calls[0];
|
||||
expect(url.toString()).toBe("https://resource.example.com/.well-known/oauth-protected-resource/path/name");
|
||||
});
|
||||
|
||||
it("preserves query parameters in path-aware discovery", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => validMetadata,
|
||||
});
|
||||
|
||||
const metadata = await discoverOAuthProtectedResourceMetadata("https://resource.example.com/path?param=value");
|
||||
expect(metadata).toEqual(validMetadata);
|
||||
const calls = mockFetch.mock.calls;
|
||||
expect(calls.length).toBe(1);
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -235,12 +235,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];
|
||||
}
|
||||
|
|
@ -327,7 +328,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
|
||||
|
|
@ -386,31 +387,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 {
|
||||
url = new URL("/.well-known/oauth-protected-resource", serverUrl);
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
||||
|
|
@ -448,8 +434,8 @@ async function fetchWithCorsRetry(
|
|||
/**
|
||||
* Constructs the well-known path for OAuth metadata discovery
|
||||
*/
|
||||
function buildWellKnownPath(pathname: string): string {
|
||||
let wellKnownPath = `/.well-known/oauth-authorization-server${pathname}`;
|
||||
function buildWellKnownPath(wellKnownPrefix: string, pathname: string): string {
|
||||
let wellKnownPath = `/.well-known/${wellKnownPrefix}${pathname}`;
|
||||
if (pathname.endsWith('/')) {
|
||||
// Strip trailing slash from pathname to avoid double slashes
|
||||
wellKnownPath = wellKnownPath.slice(0, -1);
|
||||
|
|
@ -477,6 +463,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.
|
||||
*
|
||||
|
|
@ -487,19 +505,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(issuer.pathname);
|
||||
const pathAwareUrl = new URL(wellKnownPath, issuer);
|
||||
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