Merge branch 'v2/main' into v2/fix/2108-oauth-failure-monitor
This commit is contained in:
commit
e09977c5c8
5 changed files with 927 additions and 13 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
AuthChallengeError,
|
||||
challengeResourceMetadataUrl,
|
||||
AuthRecoveryRequiredError,
|
||||
findNestedAuthError,
|
||||
isAuthChallengeError,
|
||||
|
|
@ -286,6 +287,58 @@ describe("parseAuthChallengeFromError", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resource_metadata (RFC 9728) preservation — #2071", () => {
|
||||
const METADATA_URL = "http://127.0.0.1:3001/custom/protected-resource";
|
||||
const HEADER = `Bearer resource_metadata="${METADATA_URL}"`;
|
||||
|
||||
it("keeps the advertised URL when parsing a response challenge", () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": HEADER },
|
||||
});
|
||||
|
||||
expect(parseAuthChallengeFromResponse(response)).toEqual({
|
||||
reason: "token_expired",
|
||||
resourceMetadataUrl: METADATA_URL,
|
||||
raw: { httpStatus: 401, wwwAuthenticate: HEADER },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the advertised URL when parsing an error challenge", () => {
|
||||
expect(
|
||||
parseAuthChallengeFromError({ status: 401, wwwAuthenticate: HEADER }),
|
||||
).toMatchObject({ resourceMetadataUrl: METADATA_URL });
|
||||
});
|
||||
|
||||
it("omits the field entirely when the challenge does not advertise one", () => {
|
||||
const response = new Response(null, {
|
||||
status: 401,
|
||||
headers: { "WWW-Authenticate": "Bearer" },
|
||||
});
|
||||
|
||||
expect(parseAuthChallengeFromResponse(response)).not.toHaveProperty(
|
||||
"resourceMetadataUrl",
|
||||
);
|
||||
});
|
||||
|
||||
it("converts the stored string to a URL at the OAuth boundary", () => {
|
||||
expect(
|
||||
challengeResourceMetadataUrl({ resourceMetadataUrl: METADATA_URL })?.href,
|
||||
).toBe(METADATA_URL);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["absent", undefined],
|
||||
["empty", ""],
|
||||
["whitespace", " "],
|
||||
["not a URL", "not-a-url"],
|
||||
])("ignores a %s resource_metadata value", (_label, value) => {
|
||||
expect(
|
||||
challengeResourceMetadataUrl({ resourceMetadataUrl: value }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthChallengeError", () => {
|
||||
it("detects AuthChallengeError instances", () => {
|
||||
const err = new AuthChallengeError({ reason: "token_expired" }, 401);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
|
||||
import { ensureCimdClientRegistration } from "@inspector/core/auth/cimd.js";
|
||||
|
|
@ -31,6 +31,10 @@ describe("ensureCimdClientRegistration", () => {
|
|||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("pre-registers URL-based client id when AS supports CIMD", async () => {
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
|
@ -97,6 +101,63 @@ describe("ensureCimdClientRegistration", () => {
|
|||
expect(storage.saveClientInformation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers protected-resource metadata at the challenge-advertised URL (#2071)", async () => {
|
||||
const metadataUrl = new URL(
|
||||
"http://127.0.0.1:9999/custom/protected-resource",
|
||||
);
|
||||
// Both discovery legs must run through the configured fetch — on web that
|
||||
// is the backend proxy, so a leg that fell back to the global would fail
|
||||
// on CORS and be swallowed.
|
||||
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === metadataUrl.href) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: SERVER_URL,
|
||||
authorization_servers: ["http://127.0.0.1:9999"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (url.includes("/.well-known/oauth-authorization-server")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: "http://127.0.0.1:9999",
|
||||
authorization_endpoint: "http://127.0.0.1:9999/oauth/authorize",
|
||||
token_endpoint: "http://127.0.0.1:9999/oauth/token",
|
||||
response_types_supported: ["code"],
|
||||
client_id_metadata_document_supported: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Response(null, { status: 404 });
|
||||
});
|
||||
// Nothing may reach the network directly; a call here means a leg fell
|
||||
// back to the global fetch.
|
||||
const globalFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", globalFetch);
|
||||
|
||||
await ensureCimdClientRegistration({
|
||||
serverUrl: SERVER_URL,
|
||||
provider: createProvider(storage),
|
||||
fetchFn,
|
||||
resourceMetadataUrl: metadataUrl,
|
||||
});
|
||||
|
||||
expect(globalFetch).not.toHaveBeenCalled();
|
||||
const requested = fetchFn.mock.calls.map(([input]) => String(input));
|
||||
expect(requested).toContain(metadataUrl.href);
|
||||
expect(
|
||||
requested.filter((url) =>
|
||||
url.includes("/.well-known/oauth-protected-resource"),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(storage.saveClientInformation).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
{ client_id: METADATA_URL },
|
||||
{ registrationKind: "cimd" },
|
||||
);
|
||||
});
|
||||
|
||||
it("no-ops when client information is already stored", async () => {
|
||||
storage.getClientInformation = vi.fn(async () => ({
|
||||
client_id: "existing-client",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createAuthChallengeInterceptFetch } from "@inspector/core/mcp/node/authChallengeFetch.js";
|
||||
import {
|
||||
createAuthChallengeInterceptFetch,
|
||||
createAuthChallengeObserverFetch,
|
||||
} from "@inspector/core/mcp/node/authChallengeFetch.js";
|
||||
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
|
||||
|
||||
describe("createAuthChallengeInterceptFetch", () => {
|
||||
|
|
@ -84,3 +87,63 @@ describe("createAuthChallengeInterceptFetch", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAuthChallengeObserverFetch", () => {
|
||||
const METADATA_URL = "http://127.0.0.1:3001/custom/protected-resource";
|
||||
|
||||
it("reports a challenge without altering the response or its body", async () => {
|
||||
const baseFetch = vi.fn(
|
||||
async () =>
|
||||
new Response("payload", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${METADATA_URL}"`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const onChallenge = vi.fn();
|
||||
const fetchFn = createAuthChallengeObserverFetch(baseFetch, onChallenge);
|
||||
|
||||
const res = await fetchFn("https://example.com/mcp");
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
// The body must survive for the transport (or the interceptor above).
|
||||
expect(await res.text()).toBe("payload");
|
||||
expect(onChallenge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resourceMetadataUrl: METADATA_URL }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a 403 challenge too", async () => {
|
||||
const baseFetch = vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 403,
|
||||
headers: { "WWW-Authenticate": 'Bearer error="insufficient_scope"' },
|
||||
}),
|
||||
);
|
||||
const onChallenge = vi.fn();
|
||||
|
||||
await createAuthChallengeObserverFetch(
|
||||
baseFetch,
|
||||
onChallenge,
|
||||
)("https://example.com/mcp");
|
||||
|
||||
expect(onChallenge).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ reason: "insufficient_scope" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("stays silent on a non-challenge response", async () => {
|
||||
const baseFetch = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
const onChallenge = vi.fn();
|
||||
|
||||
const res = await createAuthChallengeObserverFetch(
|
||||
baseFetch,
|
||||
onChallenge,
|
||||
)("https://example.com/mcp");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(onChallenge).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1768,6 +1768,153 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resource_metadata forwarding (RFC 9728) — #2071", () => {
|
||||
const METADATA_URL = "http://127.0.0.1:3001/custom/protected-resource";
|
||||
|
||||
it("forwards the advertised metadata URL to auth() as a URL", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
||||
await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
resourceMetadataUrl: METADATA_URL,
|
||||
});
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
resourceMetadataUrl: new URL(METADATA_URL),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the metadata URL undefined when the challenge advertises none", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
||||
await manager.handleAuthChallenge({ reason: "token_expired" });
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ resourceMetadataUrl: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores a malformed metadata URL rather than failing authorization", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const manager = new OAuthManager(createMockParams());
|
||||
|
||||
const outcome = await manager.handleAuthChallenge({
|
||||
reason: "token_expired",
|
||||
resourceMetadataUrl: "not-a-url",
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ kind: "satisfied" });
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ resourceMetadataUrl: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the last observed challenge on the plain authenticate() path", async () => {
|
||||
// The legacy first-authorization path: no challenge is passed in, so the
|
||||
// one the transport observed is the only source (#2071).
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(new URL("https://auth.example.com/authorize?state=x"));
|
||||
|
||||
manager.noteObservedAuthChallenge({
|
||||
reason: "unauthorized",
|
||||
resourceMetadataUrl: METADATA_URL,
|
||||
});
|
||||
await manager.authenticate();
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
resourceMetadataUrl: new URL(METADATA_URL),
|
||||
}),
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears the observed URL when a later challenge advertises none", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("REDIRECT");
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
const captureSpy = vi
|
||||
.spyOn(
|
||||
(await import("@inspector/core/auth/providers.js"))
|
||||
.BaseOAuthClientProvider.prototype,
|
||||
"getCapturedAuthUrl",
|
||||
)
|
||||
.mockReturnValue(new URL("https://auth.example.com/authorize?state=x"));
|
||||
|
||||
manager.noteObservedAuthChallenge({
|
||||
reason: "unauthorized",
|
||||
resourceMetadataUrl: METADATA_URL,
|
||||
});
|
||||
manager.noteObservedAuthChallenge({ reason: "unauthorized" });
|
||||
await manager.authenticate();
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ resourceMetadataUrl: undefined }),
|
||||
);
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("forwards it on the forced step-up reauthorization leg too", async () => {
|
||||
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
|
||||
const params = createMockParams();
|
||||
const noScopeTokens = {
|
||||
access_token: "a",
|
||||
token_type: "Bearer",
|
||||
scope: "",
|
||||
};
|
||||
const midScopeTokens = {
|
||||
access_token: "b",
|
||||
token_type: "Bearer",
|
||||
scope: "newscope",
|
||||
};
|
||||
const grantedTokens = {
|
||||
access_token: "d",
|
||||
token_type: "Bearer",
|
||||
scope: "granted:scope",
|
||||
};
|
||||
storageOf(params)
|
||||
.getTokens.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(noScopeTokens)
|
||||
.mockResolvedValueOnce(noScopeTokens)
|
||||
.mockResolvedValueOnce(midScopeTokens)
|
||||
.mockResolvedValueOnce(grantedTokens);
|
||||
const manager = new OAuthManager(params);
|
||||
|
||||
await manager.handleAuthChallenge({
|
||||
reason: "insufficient_scope",
|
||||
resourceMetadataUrl: METADATA_URL,
|
||||
});
|
||||
|
||||
expect(mockedMcpAuth).toHaveBeenCalledTimes(2);
|
||||
expect(mockedMcpAuth).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
forceReauthorization: true,
|
||||
resourceMetadataUrl: new URL(METADATA_URL),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthChallenge (additional branch coverage)", () => {
|
||||
it("resolves via the second satisfaction check inside the mutex", async () => {
|
||||
const params = createMockParams();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* RFC 9728 `resource_metadata` end-to-end, against a real OAuth test server
|
||||
* whose protected-resource metadata document lives at a non-default path and
|
||||
* whose well-known locations deliberately 404 (#2071).
|
||||
*
|
||||
* The unit tests prove the URL is parsed and handed to `mcpAuth()`. This file
|
||||
* proves the two halves that only a real server can: that the challenge the
|
||||
* server actually emits parses into `AuthChallenge.resourceMetadataUrl`, and
|
||||
* that SDK discovery genuinely cannot find the document without it — so the
|
||||
* plumbing is load-bearing rather than merely present.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { discoverOAuthProtectedResourceMetadata } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TestServerHttp,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
isOriginRelativePath,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
waitForOAuthWellKnown,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import {
|
||||
challengeResourceMetadataUrl,
|
||||
parseAuthChallengeFromResponse,
|
||||
} from "@inspector/core/auth/challenge.js";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import {
|
||||
ConsoleNavigation,
|
||||
type RedirectUrlProvider,
|
||||
} from "@inspector/core/auth/providers.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
|
||||
const METADATA_PATH = "/custom/protected-resource";
|
||||
const STATIC_CLIENT_ID = "test-2071-resource-metadata";
|
||||
const STATIC_CLIENT_SECRET = "test-2071-secret";
|
||||
const REDIRECT_URL = "http://localhost:3000/oauth/callback";
|
||||
/** Fixed, since this one is asserted against a server of its own. */
|
||||
const METADATA_URL_LITERAL = "http://127.0.0.1:3001/custom/protected-resource";
|
||||
|
||||
describe("OAuth challenge resource_metadata (RFC 9728)", () => {
|
||||
let mcpServer: TestServerHttp | null = null;
|
||||
let serverUrl = "";
|
||||
let storageDir = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
mcpServer = new TestServerHttp({
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "streamable-http" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
resourceMetadataPath: METADATA_PATH,
|
||||
// Static rather than DCR: this suite is about *where the metadata
|
||||
// document is found*, so registration should not be a second thing
|
||||
// that can fail.
|
||||
staticClients: [
|
||||
{
|
||||
clientId: STATIC_CLIENT_ID,
|
||||
clientSecret: STATIC_CLIENT_SECRET,
|
||||
redirectUris: [REDIRECT_URL],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const port = await mcpServer.start();
|
||||
serverUrl = `http://localhost:${port}`;
|
||||
storageDir = mkdtempSync(join(tmpdir(), "mcp-inspector-2071-"));
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mcpServer?.stop();
|
||||
mcpServer = null;
|
||||
if (storageDir) {
|
||||
rmSync(storageDir, { recursive: true, force: true });
|
||||
storageDir = "";
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("advertises the non-default metadata URL on the 401 challenge", async () => {
|
||||
const response = await fetch(`${serverUrl}/mcp`, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
const challenge = parseAuthChallengeFromResponse(response);
|
||||
expect(challenge?.resourceMetadataUrl).toBe(`${serverUrl}${METADATA_PATH}`);
|
||||
expect(challengeResourceMetadataUrl(challenge!)?.href).toBe(
|
||||
`${serverUrl}${METADATA_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("serves the metadata document only from the advertised path", async () => {
|
||||
const advertised = await fetch(`${serverUrl}${METADATA_PATH}`);
|
||||
expect(advertised.status).toBe(200);
|
||||
await expect(advertised.json()).resolves.toMatchObject({
|
||||
authorization_servers: [serverUrl],
|
||||
});
|
||||
|
||||
const wellKnown = await fetch(
|
||||
`${serverUrl}/.well-known/oauth-protected-resource`,
|
||||
);
|
||||
expect(wellKnown.status).toBe(404);
|
||||
});
|
||||
|
||||
it("SDK discovery resolves with the advertised URL and fails without it", async () => {
|
||||
await expect(
|
||||
discoverOAuthProtectedResourceMetadata(serverUrl, {
|
||||
resourceMetadataUrl: new URL(`${serverUrl}${METADATA_PATH}`),
|
||||
}),
|
||||
).resolves.toMatchObject({ authorization_servers: [serverUrl] });
|
||||
|
||||
// The pre-fix behavior: discovery derived from the MCP server URL alone.
|
||||
await expect(
|
||||
discoverOAuthProtectedResourceMetadata(serverUrl),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("drives first-time legacy authorization off the advertised URL (#2071)", async () => {
|
||||
// The gap the challenge-driven plumbing alone does not close: with no
|
||||
// stored token in the default legacy era, `InspectorClient` builds the
|
||||
// transport with no `authProvider` and no challenge interception, so the
|
||||
// 401 reaches the client as the SDK's headerless `UnauthorizedError` and
|
||||
// the CLI/TUI runner calls `authenticate()` with nothing in hand. The
|
||||
// observed-challenge path is what carries the URL across that gap.
|
||||
const requested: string[] = [];
|
||||
const spyFetch: typeof fetch = async (input, init) => {
|
||||
requested.push(input instanceof Request ? input.url : String(input));
|
||||
return fetch(input, init);
|
||||
};
|
||||
|
||||
const redirectUrlProvider: RedirectUrlProvider = {
|
||||
getRedirectUrl: () => REDIRECT_URL,
|
||||
};
|
||||
|
||||
const client = new InspectorClient(
|
||||
{ type: "streamable-http", url: `${serverUrl}/mcp` },
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
fetch: spyFetch,
|
||||
oauth: {
|
||||
// A fresh path per run. SDK `auth()` persists its discovery
|
||||
// state — the advertised metadata URL included — so a reused file
|
||||
// would let a watch-mode rerun satisfy discovery from the cache
|
||||
// and pass even with the observer plumbing removed (Copilot).
|
||||
storage: new NodeOAuthStorage(join(storageDir, "oauth.json")),
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: STATIC_CLIENT_ID,
|
||||
clientSecret: STATIC_CLIENT_SECRET,
|
||||
scope: "mcp",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await expect(client.connect()).rejects.toThrow();
|
||||
|
||||
// Reaching an authorization URL at all means discovery resolved — which it
|
||||
// can only have done through the advertised path, since the well-known one
|
||||
// 404s on this server.
|
||||
const authUrl = await client.authenticate();
|
||||
expect(authUrl?.href).toContain("/oauth/authorize");
|
||||
|
||||
expect(requested).toContain(`${serverUrl}${METADATA_PATH}`);
|
||||
expect(
|
||||
requested.filter((url) =>
|
||||
url.includes("/.well-known/oauth-protected-resource"),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
await client.disconnect();
|
||||
}, 30_000);
|
||||
|
||||
it("observes the SSE challenge through a caller-supplied eventSourceInit.fetch", async () => {
|
||||
// That explicit fetch is deliberately not proxy- or intercept-wrapped, so
|
||||
// it used to bypass the observer entirely and a first-time legacy SSE
|
||||
// authorization lost the advertised URL the same way (Copilot).
|
||||
const sseServer = createServer((_req, res) => {
|
||||
res.writeHead(401, {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${METADATA_URL_LITERAL}"`,
|
||||
});
|
||||
res.end();
|
||||
});
|
||||
await new Promise<void>((resolve) => sseServer.listen(0, resolve));
|
||||
const address = sseServer.address();
|
||||
/* v8 ignore next 3 -- listen(0) on a TCP socket always yields an AddressInfo */
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("expected a TCP address");
|
||||
}
|
||||
|
||||
const explicitFetch = vi.fn<typeof fetch>((input, init) =>
|
||||
fetch(input, init),
|
||||
);
|
||||
const observed: AuthChallenge[] = [];
|
||||
|
||||
try {
|
||||
const { transport } = createTransportNode(
|
||||
{
|
||||
type: "sse",
|
||||
url: `http://127.0.0.1:${address.port}/sse`,
|
||||
eventSourceInit: { fetch: explicitFetch },
|
||||
},
|
||||
{
|
||||
onAuthChallengeObserved: (challenge) => observed.push(challenge),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(transport.start()).rejects.toThrow();
|
||||
await transport.close().catch(() => {});
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => sseServer.close(() => resolve()));
|
||||
}
|
||||
|
||||
expect(explicitFetch).toHaveBeenCalled();
|
||||
expect(observed).toContainEqual(
|
||||
expect.objectContaining({ resourceMetadataUrl: METADATA_URL_LITERAL }),
|
||||
);
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the showcase config valid and carrying the custom path", () => {
|
||||
const configPath = fileURLToPath(
|
||||
new URL(
|
||||
"../../../../../../test-servers/configs/oauth-custom-resource-metadata-http.json",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
// loadConfig validates as it parses, so this is the regression test for
|
||||
// the checked-in showcase config as much as for the new field.
|
||||
expect(resolveConfig(loadConfig(configPath)).oauth).toMatchObject({
|
||||
enabled: true,
|
||||
resourceMetadataPath: METADATA_PATH,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/custom/protected-resource", true],
|
||||
["/a", true],
|
||||
// Both re-point the origin when resolved against the request base while
|
||||
// Express still registers the route locally, so the server would advertise
|
||||
// a document it does not serve (Copilot).
|
||||
["//other-host/doc", false],
|
||||
["/\\other-host/doc", false],
|
||||
["https://other-host/doc", false],
|
||||
["custom/protected-resource", false],
|
||||
["/has a space", false],
|
||||
// `href` preserves both, so these pass an origin+href comparison — but
|
||||
// Express matches on the path alone and a fragment never reaches the
|
||||
// server, so the advertised URL could not resolve to the route.
|
||||
["/doc?version=1", false],
|
||||
["/doc#section", false],
|
||||
["", false],
|
||||
])("origin-relative path check: %j -> %s", (value, expected) => {
|
||||
expect(isOriginRelativePath(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue