Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items
This commit is contained in:
commit
0f31de83af
8 changed files with 1782 additions and 138 deletions
111
clients/web/src/test/core/auth/authorizationParams.test.ts
Normal file
111
clients/web/src/test/core/auth/authorizationParams.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
RESERVED_AUTHORIZATION_PARAMS,
|
||||
applyAuthorizationParams,
|
||||
authorizationParamKeyError,
|
||||
isReservedAuthorizationParam,
|
||||
} from "@inspector/core/auth/authorizationParams.js";
|
||||
|
||||
const AUTHORIZE_URL =
|
||||
"https://as.example.com/authorize?client_id=abc&response_type=code&state=xyz";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("isReservedAuthorizationParam", () => {
|
||||
it.each(RESERVED_AUTHORIZATION_PARAMS)("reserves %s", (key) => {
|
||||
expect(isReservedAuthorizationParam(key)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores case and surrounding whitespace", () => {
|
||||
expect(isReservedAuthorizationParam(" Client_ID ")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows a provider-specific parameter", () => {
|
||||
expect(isReservedAuthorizationParam("kc_idp_hint")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorizationParamKeyError", () => {
|
||||
it("returns no error for a blank key (a half-edited row)", () => {
|
||||
expect(authorizationParamKeyError("")).toBeUndefined();
|
||||
expect(authorizationParamKeyError(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns no error for an allowed key", () => {
|
||||
expect(authorizationParamKeyError("login_hint")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the offending key for a reserved one", () => {
|
||||
expect(authorizationParamKeyError(" state ")).toBe(
|
||||
'"state" is set by the authorization flow and cannot be overridden.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAuthorizationParams", () => {
|
||||
it("returns the same URL instance when no params are configured", () => {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
expect(applyAuthorizationParams(url, undefined)).toBe(url);
|
||||
});
|
||||
|
||||
it("returns the same URL instance when the params object is empty", () => {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
expect(applyAuthorizationParams(url, {})).toBe(url);
|
||||
});
|
||||
|
||||
it("appends custom params without mutating the input URL", () => {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
const merged = applyAuthorizationParams(url, {
|
||||
kc_idp_hint: "corp-idp",
|
||||
login_hint: "user@example.com",
|
||||
});
|
||||
|
||||
expect(merged).not.toBe(url);
|
||||
expect(url.searchParams.get("kc_idp_hint")).toBeNull();
|
||||
expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp");
|
||||
expect(merged.searchParams.get("login_hint")).toBe("user@example.com");
|
||||
// The SDK-built parameters survive untouched.
|
||||
expect(merged.searchParams.get("client_id")).toBe("abc");
|
||||
expect(merged.searchParams.get("state")).toBe("xyz");
|
||||
});
|
||||
|
||||
it("trims the key and preserves the value verbatim", () => {
|
||||
const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), {
|
||||
" prompt ": " login ",
|
||||
});
|
||||
expect(merged.searchParams.get("prompt")).toBe(" login ");
|
||||
});
|
||||
|
||||
it("skips blank keys", () => {
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
const merged = applyAuthorizationParams(url, { " ": "nope" });
|
||||
expect(merged).toBe(url);
|
||||
});
|
||||
|
||||
it("drops a reserved key with a warning rather than overriding it", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const merged = applyAuthorizationParams(new URL(AUTHORIZE_URL), {
|
||||
state: "attacker-controlled",
|
||||
kc_idp_hint: "corp-idp",
|
||||
});
|
||||
|
||||
expect(merged.searchParams.get("state")).toBe("xyz");
|
||||
expect(merged.searchParams.get("kc_idp_hint")).toBe("corp-idp");
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]?.[0]).toContain("state");
|
||||
});
|
||||
|
||||
it("returns the original URL when every configured key is reserved", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
expect(
|
||||
applyAuthorizationParams(url, {
|
||||
Client_Id: "spoofed",
|
||||
code_challenge: "spoofed",
|
||||
}),
|
||||
).toBe(url);
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
||||
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
|
||||
import {
|
||||
BaseOAuthClientProvider,
|
||||
CallbackNavigation,
|
||||
MutableRedirectUrlProvider,
|
||||
} from "@inspector/core/auth/providers.js";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js";
|
||||
import { OAuthMemoryStore } from "@inspector/core/auth/store.js";
|
||||
import type { OAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js";
|
||||
import type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js";
|
||||
import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js";
|
||||
import {
|
||||
|
|
@ -18,6 +26,20 @@ const startIdpMock = vi.mocked(startEmaIdpAuthorization);
|
|||
|
||||
const SERVER_URL = "http://127.0.0.1:9999/mcp";
|
||||
|
||||
/**
|
||||
* A real `OAuthStorage` — in-memory state over a no-op persist backend — for the
|
||||
* one test below that drives an actual `BaseOAuthClientProvider`. Building the
|
||||
* genuine article costs two lines and avoids an `as unknown as` stub that would
|
||||
* stop type-checking against the interface the provider is handed.
|
||||
*/
|
||||
function makeRealStorage(): OAuthStorage {
|
||||
const backend: OAuthPersistBackend = {
|
||||
read: async () => null,
|
||||
write: async () => {},
|
||||
};
|
||||
return new OAuthStorageBase(new OAuthMemoryStore(), backend);
|
||||
}
|
||||
|
||||
function jwtWithExp(expSec: number): string {
|
||||
const payload = btoa(JSON.stringify({ exp: expSec }))
|
||||
.replace(/\+/g, "-")
|
||||
|
|
@ -39,6 +61,7 @@ interface FakeInner {
|
|||
tokens: ReturnType<typeof vi.fn>;
|
||||
saveTokens: ReturnType<typeof vi.fn>;
|
||||
redirectToAuthorization: ReturnType<typeof vi.fn>;
|
||||
redirectToExternalAuthorization: ReturnType<typeof vi.fn>;
|
||||
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
|
||||
saveCodeVerifier: ReturnType<typeof vi.fn>;
|
||||
codeVerifier: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -55,6 +78,7 @@ function createInner(): FakeInner {
|
|||
tokens: vi.fn(),
|
||||
saveTokens: vi.fn(),
|
||||
redirectToAuthorization: vi.fn(),
|
||||
redirectToExternalAuthorization: vi.fn(),
|
||||
clearCapturedAuthUrl: vi.fn(),
|
||||
saveCodeVerifier: vi.fn(),
|
||||
codeVerifier: vi.fn(() => "verifier-xyz"),
|
||||
|
|
@ -178,6 +202,43 @@ describe("EmaTransportOAuthProvider", () => {
|
|||
|
||||
expect(startIdpMock).toHaveBeenCalledWith(emaConfig);
|
||||
expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1);
|
||||
expect(inner.redirectToAuthorization).toHaveBeenCalledWith(idpUrl);
|
||||
expect(inner.redirectToExternalAuthorization).toHaveBeenCalledWith(idpUrl);
|
||||
// The custom-parameter merge lives on `redirectToAuthorization`, so the EMA
|
||||
// leg must never take that path.
|
||||
expect(inner.redirectToAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2018 — the inner provider carries the per-server custom authorization
|
||||
// parameters, which belong to the MCP server's authorization server. The EMA
|
||||
// leg sends the user to a *different* authorization server (the enterprise
|
||||
// IdP), so those parameters must not reach it. Driven against a real
|
||||
// BaseOAuthClientProvider rather than the fake inner above, since the defect
|
||||
// is precisely in what the real provider does with the URL it is handed.
|
||||
it("does not leak custom authorization params onto the IdP authorize URL", async () => {
|
||||
const navCallback = vi.fn();
|
||||
const realInner = new BaseOAuthClientProvider(SERVER_URL, {
|
||||
// A real OAuthStorage rather than a cast stub: the redirect path never
|
||||
// reads it, and a genuine implementation keeps this test honest if the
|
||||
// storage contract changes.
|
||||
storage: makeRealStorage(),
|
||||
redirectUrlProvider: new MutableRedirectUrlProvider(),
|
||||
navigation: new CallbackNavigation(navCallback),
|
||||
authorizationParams: { kc_idp_hint: "corp-idp", audience: "api://mcp" },
|
||||
});
|
||||
const emaProvider = new EmaTransportOAuthProvider(realInner, emaConfig);
|
||||
|
||||
const idpUrl = new URL("https://idp.test/authorize?state=abc");
|
||||
startIdpMock.mockResolvedValue(idpUrl);
|
||||
|
||||
await emaProvider.redirectToAuthorization(
|
||||
new URL("https://resource-as.test/authorize"),
|
||||
);
|
||||
|
||||
const navigated = navCallback.mock.calls[0]?.[0] as URL;
|
||||
expect(navigated.searchParams.get("kc_idp_hint")).toBeNull();
|
||||
expect(navigated.searchParams.get("audience")).toBeNull();
|
||||
expect(navigated.href).toBe(idpUrl.href);
|
||||
// The captured URL (what the step-up UI shows) must agree.
|
||||
expect(realInner.getCapturedAuthUrl()?.href).toBe(idpUrl.href);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -352,6 +352,68 @@ describe("OAuthNavigation", () => {
|
|||
expect(navCallback).toHaveBeenCalledWith(url);
|
||||
});
|
||||
|
||||
// #2018 — the provider is the seam where per-server custom parameters reach
|
||||
// the authorize URL, since the SDK builds that URL with no hook of its own.
|
||||
function makeProviderWithParams(
|
||||
storage: OAuthStorage,
|
||||
authorizationParams: Record<string, string>,
|
||||
navCallback = vi.fn(),
|
||||
): BaseOAuthClientProvider {
|
||||
return new BaseOAuthClientProvider(SERVER, {
|
||||
storage,
|
||||
redirectUrlProvider: new MutableRedirectUrlProvider(),
|
||||
navigation: new CallbackNavigation(navCallback),
|
||||
authorizationParams,
|
||||
});
|
||||
}
|
||||
|
||||
it("redirectToAuthorization merges configured authorization params", () => {
|
||||
const storage = makeStorage();
|
||||
const navCallback = vi.fn();
|
||||
const provider = makeProviderWithParams(
|
||||
storage,
|
||||
{ kc_idp_hint: "corp-idp" },
|
||||
navCallback,
|
||||
);
|
||||
const target = new EventTarget();
|
||||
const handler = vi.fn();
|
||||
target.addEventListener("oauthAuthorizationRequired", handler);
|
||||
provider.setEventTarget(target);
|
||||
|
||||
const url = new URL("https://mcp.example.com/authorize?client_id=abc");
|
||||
provider.redirectToAuthorization(url);
|
||||
|
||||
const navigated = navCallback.mock.calls[0]?.[0] as URL;
|
||||
expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp");
|
||||
expect(navigated.searchParams.get("client_id")).toBe("abc");
|
||||
// The captured URL, the event detail, and the navigation all agree.
|
||||
expect(provider.getCapturedAuthUrl()?.href).toBe(navigated.href);
|
||||
const detail = (handler.mock.calls[0]?.[0] as CustomEvent<{ url: URL }>)
|
||||
.detail;
|
||||
expect(detail.url.href).toBe(navigated.href);
|
||||
// The URL the SDK handed in is left untouched.
|
||||
expect(url.searchParams.get("kc_idp_hint")).toBeNull();
|
||||
});
|
||||
|
||||
it("redirectToAuthorization refuses a reserved authorization param", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const storage = makeStorage();
|
||||
const navCallback = vi.fn();
|
||||
const provider = makeProviderWithParams(
|
||||
storage,
|
||||
{ state: "spoofed" },
|
||||
navCallback,
|
||||
);
|
||||
|
||||
const url = new URL("https://mcp.example.com/authorize?state=real");
|
||||
provider.redirectToAuthorization(url);
|
||||
|
||||
const navigated = navCallback.mock.calls[0]?.[0] as URL;
|
||||
expect(navigated.searchParams.get("state")).toBe("real");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("delegates token and scope persistence to storage", async () => {
|
||||
const storage = makeStorage();
|
||||
const provider = makeProvider(storage);
|
||||
|
|
|
|||
|
|
@ -86,6 +86,44 @@ describe("runner client auth options", () => {
|
|||
expect(opts.oauth?.clientId).toBe("resource-client");
|
||||
});
|
||||
|
||||
// #2018 — the CLI/TUI leg picks the authorization parameters up from the same
|
||||
// per-server settings the web leg reads, so all three clients agree.
|
||||
it("buildRunnerClientAuthOptions forwards custom authorization params", () => {
|
||||
const settings: InspectorServerSettings = {
|
||||
oauthAuthorizationParams: [
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
{ key: "", value: "dropped" },
|
||||
],
|
||||
requestTimeout: 0,
|
||||
connectionTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 10,
|
||||
autoRefreshOnListChanged: false,
|
||||
metadata: [],
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
};
|
||||
const opts = buildRunnerClientAuthOptions({}, settings);
|
||||
expect(opts.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" });
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions ignores all-blank authorization param rows", () => {
|
||||
const settings: InspectorServerSettings = {
|
||||
oauthAuthorizationParams: [{ key: " ", value: "x" }],
|
||||
requestTimeout: 0,
|
||||
connectionTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 10,
|
||||
autoRefreshOnListChanged: false,
|
||||
metadata: [],
|
||||
headers: [],
|
||||
env: [],
|
||||
roots: [],
|
||||
};
|
||||
expect(buildRunnerClientAuthOptions({}, settings)).toEqual({});
|
||||
});
|
||||
|
||||
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
|
||||
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1574,6 +1574,53 @@ describe("OAuthManager", () => {
|
|||
const provider = await manager.createOAuthProviderForTransport();
|
||||
expect(provider.constructor.name).toBe("EmaTransportOAuthProvider");
|
||||
});
|
||||
|
||||
// #2018 — the manager→provider bridge for the custom authorization
|
||||
// parameters. The provider test proves the merge and the runner test proves
|
||||
// the options object, but neither crosses this seam: without this case,
|
||||
// deleting `authorizationParams:` from `createOAuthProvider` would leave the
|
||||
// feature dead with every other test still green.
|
||||
it("forwards configured authorizationParams to the provider it builds", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({
|
||||
authorizationParams: { kc_idp_hint: "corp-idp", prompt: "login" },
|
||||
});
|
||||
|
||||
const provider = await manager.createOAuthProviderForTransport();
|
||||
provider.redirectToAuthorization(
|
||||
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
|
||||
);
|
||||
|
||||
const navigate = params.initialConfig.navigation
|
||||
?.navigateToAuthorization as ReturnType<typeof vi.fn>;
|
||||
const navigated = navigate.mock.calls[0]?.[0] as URL;
|
||||
expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp");
|
||||
expect(navigated.searchParams.get("prompt")).toBe("login");
|
||||
// The flow's own parameters are untouched.
|
||||
expect(navigated.searchParams.get("state")).toBe("xyz");
|
||||
});
|
||||
|
||||
it("drops a reserved key configured on the manager", async () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
manager.setOAuthConfig({
|
||||
authorizationParams: { state: "spoofed", kc_idp_hint: "corp-idp" },
|
||||
});
|
||||
|
||||
const provider = await manager.createOAuthProviderForTransport();
|
||||
provider.redirectToAuthorization(
|
||||
new URL("https://as.example.com/authorize?client_id=abc&state=xyz"),
|
||||
);
|
||||
|
||||
const navigate = params.initialConfig.navigation
|
||||
?.navigateToAuthorization as ReturnType<typeof vi.fn>;
|
||||
const navigated = navigate.mock.calls[0]?.[0] as URL;
|
||||
expect(navigated.searchParams.get("state")).toBe("xyz");
|
||||
expect(navigated.searchParams.get("kc_idp_hint")).toBe("corp-idp");
|
||||
expect(warn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOAuthState (enterprise managed / scope)", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
cleanAuthorizationParams,
|
||||
cleanRoots,
|
||||
DEFAULT_SEED_CONFIG,
|
||||
envPairsToRecord,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
mcpConfigToServerEntries,
|
||||
mergeSecretsIntoStored,
|
||||
normalizeServerType,
|
||||
oauthAuthorizationParamsFromSettings,
|
||||
serverEntriesToMcpConfig,
|
||||
serializeMcpConfig,
|
||||
storedFieldsToInspectorSettings,
|
||||
|
|
@ -924,6 +926,42 @@ describe("extractSecretsFromStored", () => {
|
|||
expect(stripped.oauth).toEqual({ enterpriseManaged: true });
|
||||
});
|
||||
|
||||
// The stripped entry now subtracts the secret instead of enumerating the
|
||||
// fields to keep, so a field added to `oauth` later survives by construction.
|
||||
// `onInsufficientScope` is the case the old allow-list had already lost.
|
||||
it("preserves oauth.onInsufficientScope when lifting clientSecret to keychain", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
oauth: {
|
||||
clientId: "cid",
|
||||
clientSecret: "shh",
|
||||
onInsufficientScope: "throw",
|
||||
},
|
||||
};
|
||||
const { stripped, secrets } = extractSecretsFromStored(stored);
|
||||
expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" });
|
||||
expect(stripped.oauth).toEqual({
|
||||
clientId: "cid",
|
||||
onInsufficientScope: "throw",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves oauth.authorizationParams when lifting clientSecret to keychain", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
oauth: {
|
||||
clientSecret: "shh",
|
||||
authorizationParams: { kc_idp_hint: "corp" },
|
||||
},
|
||||
};
|
||||
const { stripped } = extractSecretsFromStored(stored);
|
||||
expect(stripped.oauth).toEqual({
|
||||
authorizationParams: { kc_idp_hint: "corp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the oauth block entirely when clientSecret was its only property", () => {
|
||||
const stored: StoredMCPServer = {
|
||||
type: "streamable-http",
|
||||
|
|
@ -1188,6 +1226,68 @@ describe("oauthOnInsufficientScope (SEP-2350)", () => {
|
|||
});
|
||||
expect(stored.oauth?.onInsufficientScope).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2018 — custom authorization-request parameters.
|
||||
it("lifts oauth.authorizationParams into settings rows", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: { authorizationParams: { kc_idp_hint: "corp", prompt: "login" } },
|
||||
});
|
||||
expect(settings?.oauthAuthorizationParams).toEqual([
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
{ key: "prompt", value: "login" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads an empty authorizationParams object back as unset", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
oauth: { clientId: "cid", authorizationParams: {} },
|
||||
});
|
||||
expect(settings?.oauthAuthorizationParams).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists authorizationParams under oauth, dropping blank-key rows", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthAuthorizationParams: [
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
{ key: " ", value: "orphan" },
|
||||
],
|
||||
});
|
||||
expect(stored.oauth?.authorizationParams).toEqual({ kc_idp_hint: "corp" });
|
||||
});
|
||||
|
||||
it("omits the oauth block when every authorization-param row is blank", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthAuthorizationParams: [{ key: "", value: "" }],
|
||||
});
|
||||
expect(stored).not.toHaveProperty("oauth");
|
||||
});
|
||||
|
||||
it("omits authorizationParams when the rows are absent", () => {
|
||||
const stored = inspectorSettingsToStoredFields({
|
||||
...baseSettings,
|
||||
oauthClientId: "cid",
|
||||
});
|
||||
expect(stored.oauth?.authorizationParams).toBeUndefined();
|
||||
});
|
||||
|
||||
it("derives the client option record, or undefined when nothing survives", () => {
|
||||
expect(
|
||||
oauthAuthorizationParamsFromSettings({
|
||||
oauthAuthorizationParams: [
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
{ key: "", value: "x" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ kc_idp_hint: "corp" });
|
||||
expect(
|
||||
oauthAuthorizationParamsFromSettings({
|
||||
oauthAuthorizationParams: [{ key: " ", value: "x" }],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(oauthAuthorizationParamsFromSettings({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("envRecordToPairs / envPairsToRecord", () => {
|
||||
|
|
@ -1316,3 +1416,94 @@ describe("stdio env / cwd mirroring", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
// #2018 — `StoredMCPServer.oauth.authorizationParams` is typed
|
||||
// `Record<string, string>`, but only the web client's `/api/servers` route
|
||||
// validates it; the CLI and TUI read `mcp.json` off disk. These cases drive the
|
||||
// shapes a hand-edited file can hold, which the type says are impossible.
|
||||
describe("cleanAuthorizationParams", () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("passes a well-formed record through", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams({ kc_idp_hint: "corp", prompt: "login" }),
|
||||
).toEqual({ kc_idp_hint: "corp", prompt: "login" });
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined for an absent or empty record", () => {
|
||||
expect(cleanAuthorizationParams(undefined)).toBeUndefined();
|
||||
expect(cleanAuthorizationParams({})).toBeUndefined();
|
||||
});
|
||||
|
||||
// A bare string enumerates as character-indexed pairs (0=o, 1=o, …), so
|
||||
// without this guard the Inspector would send four invented parameters.
|
||||
it("rejects a string with a warning instead of enumerating its characters", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams("oops" as unknown as Record<string, string>),
|
||||
).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an array with a warning", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams(["a"] as unknown as Record<string, string>),
|
||||
).toBeUndefined();
|
||||
expect(warnSpy.mock.calls[0]?.[1]).toBe("array");
|
||||
});
|
||||
|
||||
it("rejects null with a warning", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams(null as unknown as Record<string, string>),
|
||||
).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// `URLSearchParams.set` stringifies whatever it is given, so a number would
|
||||
// otherwise reach the authorize URL as "5".
|
||||
it("drops a non-string value with a warning and keeps the rest", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams({
|
||||
audience: 5,
|
||||
kc_idp_hint: "corp",
|
||||
} as unknown as Record<string, string>),
|
||||
).toEqual({ kc_idp_hint: "corp" });
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns undefined when every value was dropped", () => {
|
||||
expect(
|
||||
cleanAuthorizationParams({ audience: 5 } as unknown as Record<
|
||||
string,
|
||||
string
|
||||
>),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a blank key", () => {
|
||||
expect(cleanAuthorizationParams({ " ": "x", ok: "y" })).toEqual({
|
||||
ok: "y",
|
||||
});
|
||||
});
|
||||
|
||||
it("is applied when lifting stored fields into settings", () => {
|
||||
const settings = storedFieldsToInspectorSettings({
|
||||
type: "streamable-http",
|
||||
url: "https://x.test",
|
||||
oauth: {
|
||||
authorizationParams: { audience: 5, kc_idp_hint: "corp" },
|
||||
},
|
||||
} as unknown as StoredMCPServer);
|
||||
expect(settings?.oauthAuthorizationParams).toEqual([
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -701,6 +701,58 @@ describe("server.ts supplemental coverage", () => {
|
|||
await stop(h);
|
||||
}
|
||||
});
|
||||
|
||||
// #2018 — authorizationParams must be a string record; a hand-edited file
|
||||
// with anything else drops the whole `oauth` node rather than feeding
|
||||
// non-string values to the authorize-URL merge.
|
||||
it("drops oauth whose authorizationParams is not a string record", async () => {
|
||||
const h = await start({
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: {
|
||||
srv: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
oauth: { authorizationParams: { kc_idp_hint: 5 } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(body.mcpServers.srv).not.toHaveProperty("oauth");
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a well-formed authorizationParams record on read", async () => {
|
||||
const h = await start({
|
||||
seedConfig: JSON.stringify({
|
||||
mcpServers: {
|
||||
srv: {
|
||||
type: "streamable-http",
|
||||
url: "https://x.test/mcp",
|
||||
oauth: { authorizationParams: { kc_idp_hint: "corp" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`);
|
||||
const body = (await res.json()) as {
|
||||
mcpServers: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
expect(body.mcpServers.srv?.oauth).toEqual({
|
||||
authorizationParams: { kc_idp_hint: "corp" },
|
||||
});
|
||||
} finally {
|
||||
await stop(h);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("plaintext-secret migration over a mixed config", () => {
|
||||
|
|
|
|||
|
|
@ -449,6 +449,53 @@ describe("/api/servers routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// #2018 — the authorization-parameter rows travel as a pair array on the
|
||||
// wire and land as a record under `oauth` on disk, mirroring `headers`.
|
||||
it("persists custom authorization params under oauth on POST", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "authparams",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthAuthorizationParams: [
|
||||
{ key: "kc_idp_hint", value: "corp" },
|
||||
{ key: "", value: "dropped" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const stored = readConfig(h.configPath).mcpServers.authparams;
|
||||
expect(stored?.oauth).toEqual({
|
||||
authorizationParams: { kc_idp_hint: "corp" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a malformed oauthAuthorizationParams payload", async () => {
|
||||
const res = await fetch(`${h.baseUrl}/api/servers`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: "badauthparams",
|
||||
config: { type: "streamable-http", url: "https://x.test/mcp" },
|
||||
settings: {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
oauthAuthorizationParams: { kc_idp_hint: "corp" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("updates Inspector-extension fields at the top level on PUT", async () => {
|
||||
writeFileSync(
|
||||
h.configPath,
|
||||
|
|
|
|||
Loading…
Reference in a new issue