Merge branch 'v2/main' into v2/fix/1909-salvage-malformed-list-items
This commit is contained in:
commit
0f31de83af
37 changed files with 1782 additions and 138 deletions
|
|
@ -38,7 +38,14 @@ v2/main/
|
||||||
│ │ # issuerBinding.ts SEP-2352 callback-leg failure
|
│ │ # issuerBinding.ts SEP-2352 callback-leg failure
|
||||||
│ │ # classification — separates a recoverable
|
│ │ # classification — separates a recoverable
|
||||||
│ │ # "lost authorization state" from a genuine
|
│ │ # "lost authorization state" from a genuine
|
||||||
│ │ # cross-AS issuer mismatch — #1808)
|
│ │ # cross-AS issuer mismatch — #1808;
|
||||||
|
│ │ # authorizationParams.ts per-server custom
|
||||||
|
│ │ # authorization-request parameters — the
|
||||||
|
│ │ # reserved-key list plus the merge applied in
|
||||||
|
│ │ # providers.redirectToAuthorization, the only
|
||||||
|
│ │ # seam that sees the SDK-built authorize URL;
|
||||||
|
│ │ # authorization request only, never the token
|
||||||
|
│ │ # request — #2018)
|
||||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer,
|
||||||
│ │ │ # runner-interactive-oauth loopback callback flow)
|
│ │ │ # runner-interactive-oauth loopback callback flow)
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedReq
|
||||||
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
import { ResourceSubscriptionsState } from "@inspector/core/mcp/state/resourceSubscriptionsState.js";
|
||||||
import {
|
import {
|
||||||
cleanRoots,
|
cleanRoots,
|
||||||
|
oauthAuthorizationParamsFromSettings,
|
||||||
serializeMcpConfig,
|
serializeMcpConfig,
|
||||||
} from "@inspector/core/mcp/serverList.js";
|
} from "@inspector/core/mcp/serverList.js";
|
||||||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||||
|
|
@ -2311,11 +2312,15 @@ function App() {
|
||||||
.map((m) => [m.key, m.value]),
|
.map((m) => [m.key, m.value]),
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const serverAuthorizationParams = savedSettings
|
||||||
|
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||||
|
: undefined;
|
||||||
const oauthFromServer =
|
const oauthFromServer =
|
||||||
savedSettings &&
|
savedSettings &&
|
||||||
(savedSettings.oauthClientId ||
|
(savedSettings.oauthClientId ||
|
||||||
savedSettings.oauthClientSecret ||
|
savedSettings.oauthClientSecret ||
|
||||||
savedSettings.oauthScopes ||
|
savedSettings.oauthScopes ||
|
||||||
|
serverAuthorizationParams ||
|
||||||
savedSettings.enterpriseManaged)
|
savedSettings.enterpriseManaged)
|
||||||
? {
|
? {
|
||||||
...(savedSettings.oauthClientId && {
|
...(savedSettings.oauthClientId && {
|
||||||
|
|
@ -2327,6 +2332,9 @@ function App() {
|
||||||
...(savedSettings.oauthScopes && {
|
...(savedSettings.oauthScopes && {
|
||||||
scope: savedSettings.oauthScopes,
|
scope: savedSettings.oauthScopes,
|
||||||
}),
|
}),
|
||||||
|
...(serverAuthorizationParams && {
|
||||||
|
authorizationParams: serverAuthorizationParams,
|
||||||
|
}),
|
||||||
...(savedSettings.enterpriseManaged && {
|
...(savedSettings.enterpriseManaged && {
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ describe("CopyButton", () => {
|
||||||
expect(screen.getByText("⎘")).toBeInTheDocument();
|
expect(screen.getByText("⎘")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names itself 'Copy' with no label, and qualifies the name when given one", () => {
|
||||||
|
const { unmount } = renderWithMantine(<CopyButton value="hello" />);
|
||||||
|
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
renderWithMantine(<CopyButton value="hello" label="ID Token" />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
it("triggers a click without throwing when clipboard is unavailable", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<CopyButton value="hello" />);
|
renderWithMantine(<CopyButton value="hello" />);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,15 @@ export interface CopyButtonProps {
|
||||||
* rows (e.g. beside a Code block). Icon size is unchanged.
|
* rows (e.g. beside a Code block). Icon size is unchanged.
|
||||||
*/
|
*/
|
||||||
flush?: boolean;
|
flush?: boolean;
|
||||||
|
/**
|
||||||
|
* Names what is being copied, for the accessible label only ("Copy ID
|
||||||
|
* Token"). Set it when more than one CopyButton can appear in the same
|
||||||
|
* region, so button-by-button screen-reader navigation can tell them apart;
|
||||||
|
* a lone control needs no qualifier. The visible tooltip stays "Copy" either
|
||||||
|
* way, and the accessible name keeps "Copy"/"Copied" as its first word so it
|
||||||
|
* still contains the visible text (WCAG 2.5.3).
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyActionIcon = ActionIcon.withProps({
|
const CopyActionIcon = ActionIcon.withProps({
|
||||||
|
|
@ -18,7 +27,8 @@ const CopyActionIcon = ActionIcon.withProps({
|
||||||
fz: 24,
|
fz: 24,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
export function CopyButton({ value, flush = false, label }: CopyButtonProps) {
|
||||||
|
const qualifier = label ? ` ${label}` : "";
|
||||||
return (
|
return (
|
||||||
<MantineCopyButton value={value}>
|
<MantineCopyButton value={value}>
|
||||||
{({ copied, copy }) => (
|
{({ copied, copy }) => (
|
||||||
|
|
@ -26,7 +36,7 @@ export function CopyButton({ value, flush = false }: CopyButtonProps) {
|
||||||
<CopyActionIcon
|
<CopyActionIcon
|
||||||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
aria-label={copied ? "Copied" : "Copy"}
|
aria-label={(copied ? "Copied" : "Copy") + qualifier}
|
||||||
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
{...(flush && { p: 0, h: "auto", w: "auto" })}
|
||||||
>
|
>
|
||||||
{copied ? "\u2713" : "\u2398"}
|
{copied ? "\u2713" : "\u2398"}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
} from "@modelcontextprotocol/client";
|
} from "@modelcontextprotocol/client";
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
import { expect, within } from "storybook/test";
|
import { expect, userEvent, within } from "storybook/test";
|
||||||
import {
|
import {
|
||||||
ConnectionInfoContent,
|
ConnectionInfoContent,
|
||||||
SERVER_INFO_NOT_REPORTED_LABEL,
|
SERVER_INFO_NOT_REPORTED_LABEL,
|
||||||
|
|
@ -161,3 +161,37 @@ export const WithOAuth: Story = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// An authorization server that also returned an `id_token` (#2019). Shown for
|
||||||
|
// inspection only — decoding it is display-only, and it is never used as a
|
||||||
|
// credential.
|
||||||
|
export const WithOAuthIdToken: Story = {
|
||||||
|
args: {
|
||||||
|
initializeResult: {
|
||||||
|
protocolVersion: "2025-03-26",
|
||||||
|
serverInfo: { name: "OIDC-fronted Server", version: "3.0.0" },
|
||||||
|
capabilities: { tools: { listChanged: true } },
|
||||||
|
},
|
||||||
|
clientCapabilities: { roots: { listChanged: true } },
|
||||||
|
transport: "streamable-http",
|
||||||
|
oauth: {
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||||
|
scopes: ["openid", "email", "read"],
|
||||||
|
accessToken: "opaque-access-token",
|
||||||
|
// header {"alg":"none"} / payload {"sub":"user-42","email":"a@b.test"}
|
||||||
|
idToken:
|
||||||
|
"eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTQyIiwiZW1haWwiOiJhQGIudGVzdCJ9.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(canvas.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
// Only the ID token is a JWT here, so exactly one decode toggle is offered.
|
||||||
|
await userEvent.click(
|
||||||
|
canvas.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
);
|
||||||
|
expect(canvas.getByText(/"sub": "user-42"/)).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,95 @@ describe("ConnectionInfoContent", () => {
|
||||||
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the ID Token row when the token set carries one", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "token-123",
|
||||||
|
idToken: "id-token-456",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("id-token-456")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the two token rows' controls distinct accessible names", () => {
|
||||||
|
// Both rows carry a copy control, and both tokens here are JWTs so both
|
||||||
|
// carry a decode toggle. Screen-reader button navigation has only the
|
||||||
|
// accessible name to go on, so the four must not collide (#2019 review).
|
||||||
|
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: jwt,
|
||||||
|
idToken: jwt,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const names = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.map((button) => button.getAttribute("aria-label") ?? button.textContent)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
expect(new Set(names).size).toBe(names.length);
|
||||||
|
|
||||||
|
for (const name of [
|
||||||
|
"Copy Access Token",
|
||||||
|
"Copy ID Token",
|
||||||
|
"Decode JWT for Access Token",
|
||||||
|
"Decode JWT for ID Token",
|
||||||
|
]) {
|
||||||
|
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the ID Token row on its own when there is no access token", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
idToken: "id-token-456",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the ID Token row when only an access token is present", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ConnectionInfoContent
|
||||||
|
initializeResult={fullResult}
|
||||||
|
clientCapabilities={fullClientCaps}
|
||||||
|
transport="streamable-http"
|
||||||
|
oauth={{
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "token-123",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ID Token")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render OAuth section when oauth prop is omitted", () => {
|
it("does not render OAuth section when oauth prop is omitted", () => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import {
|
||||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||||
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
import { EraBadge } from "../../elements/EraBadge/EraBadge";
|
||||||
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
import { isModernEra } from "../../elements/EraBadge/eraUtils";
|
||||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
import { OAuthTokenField } from "./OAuthTokenField";
|
||||||
|
|
||||||
export interface OAuthDetails {
|
export interface OAuthDetails {
|
||||||
protocol: "standard" | "ema";
|
protocol: "standard" | "ema";
|
||||||
|
|
@ -34,6 +34,13 @@ export interface OAuthDetails {
|
||||||
authUrl?: string;
|
authUrl?: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
|
/**
|
||||||
|
* OIDC `id_token` from the stored token set, when the authorization server
|
||||||
|
* returned one. Shown for inspection only — the MCP authorization spec is
|
||||||
|
* plain OAuth 2.1 and the Inspector never treats this as a credential
|
||||||
|
* (#2019). Absent when the token set carries none, so no empty row renders.
|
||||||
|
*/
|
||||||
|
idToken?: string;
|
||||||
/** EMA only — install-level IdP session for legs 1–2. */
|
/** EMA only — install-level IdP session for legs 1–2. */
|
||||||
idpSession?: "none" | "logged_in" | "expired";
|
idpSession?: "none" | "logged_in" | "expired";
|
||||||
}
|
}
|
||||||
|
|
@ -370,20 +377,27 @@ export function ConnectionInfoContent({
|
||||||
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
|
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
{oauth.accessToken ? (
|
{oauth.accessToken && (
|
||||||
<OAuthAccessTokenField
|
<OAuthTokenField
|
||||||
accessToken={oauth.accessToken}
|
label="Access Token"
|
||||||
|
token={oauth.accessToken}
|
||||||
onClear={onClearOAuth}
|
onClear={onClearOAuth}
|
||||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
onClearOAuth && (
|
{/* Viewer only — an `id_token` the AS happened to return, decoded
|
||||||
<Flex justify="flex-end">
|
on request. It carries no clear action: clearing OAuth state is
|
||||||
<ClearOAuthButton onClick={onClearOAuth}>
|
one action for the whole token set, owned by the access-token
|
||||||
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
row (or the standalone button below when there is none). */}
|
||||||
</ClearOAuthButton>
|
{oauth.idToken && (
|
||||||
</Flex>
|
<OAuthTokenField label="ID Token" token={oauth.idToken} />
|
||||||
)
|
)}
|
||||||
|
{!oauth.accessToken && onClearOAuth && (
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
<ClearOAuthButton onClick={onClearOAuth}>
|
||||||
|
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
|
</ClearOAuthButton>
|
||||||
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|
||||||
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
|
|
||||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
|
||||||
|
|
||||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
|
||||||
|
|
||||||
describe("OAuthAccessTokenField", () => {
|
|
||||||
it("renders token with copy control beside the content", () => {
|
|
||||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
|
||||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
|
||||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
|
||||||
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Show token" }));
|
|
||||||
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits decode toggle for opaque tokens", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: "Decode JWT" }),
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies decoded JSON while decode view is shown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
||||||
Object.defineProperty(navigator, "clipboard", {
|
|
||||||
value: { writeText },
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
|
|
||||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('"sub": "user"'),
|
|
||||||
);
|
|
||||||
expect(writeText).not.toHaveBeenCalledWith(jwt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies the raw token while token view is shown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
||||||
Object.defineProperty(navigator, "clipboard", {
|
|
||||||
value: { writeText },
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Copy" }));
|
|
||||||
|
|
||||||
expect(writeText).toHaveBeenCalledWith(jwt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders clear action on the access token header row", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onClear = vi.fn();
|
|
||||||
renderWithMantine(
|
|
||||||
<OAuthAccessTokenField
|
|
||||||
accessToken="opaque-access-token-value"
|
|
||||||
onClear={onClear}
|
|
||||||
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await user.click(
|
|
||||||
screen.getByRole("button", {
|
|
||||||
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(onClear).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
|
||||||
|
import { OAuthTokenField } from "./OAuthTokenField";
|
||||||
|
|
||||||
|
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||||
|
|
||||||
|
describe("OAuthTokenField", () => {
|
||||||
|
it("renders token with copy control beside the content", () => {
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Copy Access Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Show token for Access Token" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits decode toggle for opaque tokens", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<OAuthTokenField
|
||||||
|
label="Access Token"
|
||||||
|
token="opaque-access-token-value"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies decoded JSON while decode view is shown", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for Access Token" }),
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('"sub": "user"'),
|
||||||
|
);
|
||||||
|
expect(writeText).not.toHaveBeenCalledWith(jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the raw token while token view is shown", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText },
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithMantine(<OAuthTokenField label="Access Token" token={jwt} />);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Copy Access Token" }));
|
||||||
|
|
||||||
|
expect(writeText).toHaveBeenCalledWith(jwt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders clear action on the access token header row", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onClear = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<OAuthTokenField
|
||||||
|
label="Access Token"
|
||||||
|
token="opaque-access-token-value"
|
||||||
|
onClear={onClear}
|
||||||
|
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onClear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the row from the label prop and decodes an ID token", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
// header {"alg":"none"} / payload {"sub":"user"} — same shape an AS returns
|
||||||
|
// for an `id_token`; the decode path is identical to the access token's.
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("ID Token")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("qualifies the decode and copy accessible names with the row label", () => {
|
||||||
|
// Two rows can render simultaneously (Access Token + ID Token), so the
|
||||||
|
// controls must not share accessible names — see #2019 review.
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Decode JWT for ID Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Copy ID Token" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the clear action when no handler is given", () => {
|
||||||
|
renderWithMantine(<OAuthTokenField label="ID Token" token={jwt} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", {
|
||||||
|
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
|
||||||
|
}),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { Fragment, useMemo, useState } from "react";
|
||||||
|
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
|
||||||
|
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||||
|
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One labelled OAuth token row: the raw value, a copy control, and — when the
|
||||||
|
* value has the three-segment JWT shape — an in-place "Decode JWT" toggle.
|
||||||
|
*
|
||||||
|
* Shared by the Access Token and ID Token rows (#2019). The decode is
|
||||||
|
* display-only (`decodeJwtPayload` does no signature verification), so this is
|
||||||
|
* a viewer: neither token is treated as a credential here.
|
||||||
|
*/
|
||||||
|
export interface OAuthTokenFieldProps {
|
||||||
|
/** Row caption, e.g. "Access Token" / "ID Token". */
|
||||||
|
label: string;
|
||||||
|
token: string;
|
||||||
|
onClear?: () => void;
|
||||||
|
clearLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CaptionRow = Flex.withProps({
|
||||||
|
justify: "space-between",
|
||||||
|
align: "center",
|
||||||
|
gap: "sm",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Caption = Text.withProps({ size: "sm" });
|
||||||
|
|
||||||
|
const Toolbar = Flex.withProps({
|
||||||
|
gap: 4,
|
||||||
|
align: "center",
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ToolbarButton = Button.withProps({
|
||||||
|
variant: "subtle",
|
||||||
|
size: "compact-xs",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenRow = Flex.withProps({
|
||||||
|
align: "flex-start",
|
||||||
|
gap: 4,
|
||||||
|
wrap: "nowrap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenColumn = Flex.withProps({
|
||||||
|
flex: 1,
|
||||||
|
miw: 0,
|
||||||
|
direction: "column",
|
||||||
|
});
|
||||||
|
|
||||||
|
const TokenCode = Code.withProps({
|
||||||
|
block: true,
|
||||||
|
py: "xs",
|
||||||
|
ps: "xs",
|
||||||
|
pe: 0,
|
||||||
|
variant: "wrapping",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
|
||||||
|
function JwtTokenText({ token }: { token: string }) {
|
||||||
|
const parts = token.split(".");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, index) => (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{index > 0 && "."}
|
||||||
|
{part}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OAuthTokenField({
|
||||||
|
label,
|
||||||
|
token,
|
||||||
|
onClear,
|
||||||
|
clearLabel = "Clear",
|
||||||
|
}: OAuthTokenFieldProps) {
|
||||||
|
const [showDecoded, setShowDecoded] = useState(false);
|
||||||
|
const isJwt = isJwtFormat(token);
|
||||||
|
const jwtDecoded = useMemo(
|
||||||
|
() => (isJwt ? decodeJwtPayload(token) : undefined),
|
||||||
|
[token, isJwt],
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedText = useMemo(() => {
|
||||||
|
if (!jwtDecoded) return undefined;
|
||||||
|
return JSON.stringify(
|
||||||
|
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}, [jwtDecoded]);
|
||||||
|
|
||||||
|
const copyValue = showDecoded && decodedText ? decodedText : token;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<CaptionRow>
|
||||||
|
<Caption>{label}</Caption>
|
||||||
|
<Toolbar>
|
||||||
|
{jwtDecoded && (
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => setShowDecoded((open) => !open)}
|
||||||
|
aria-pressed={showDecoded}
|
||||||
|
aria-label={`${showDecoded ? "Show token" : "Decode JWT"} for ${label}`}
|
||||||
|
>
|
||||||
|
{showDecoded ? "Show token" : "Decode JWT"}
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
{onClear && (
|
||||||
|
<ToolbarButton color="red" onClick={onClear}>
|
||||||
|
{clearLabel}
|
||||||
|
</ToolbarButton>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</CaptionRow>
|
||||||
|
<TokenRow>
|
||||||
|
<TokenColumn>
|
||||||
|
<TokenCode>
|
||||||
|
{showDecoded && decodedText ? (
|
||||||
|
decodedText
|
||||||
|
) : isJwt ? (
|
||||||
|
<JwtTokenText token={token} />
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
)}
|
||||||
|
</TokenCode>
|
||||||
|
</TokenColumn>
|
||||||
|
<CopyButton value={copyValue} flush label={label} />
|
||||||
|
</TokenRow>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,39 @@ describe("oauthDetailsFromConnectionState", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("surfaces an id_token when the token set carries one", () => {
|
||||||
|
const state: OAuthConnectionState = {
|
||||||
|
authorized: true,
|
||||||
|
protocol: "standard",
|
||||||
|
serverUrl: "https://mcp.example.com/mcp",
|
||||||
|
tokens: {
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
id_token: "header.payload.signature",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||||
|
protocol: "standard",
|
||||||
|
authorized: true,
|
||||||
|
accessToken: "tok",
|
||||||
|
idToken: "header.payload.signature",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits idToken when the token set carries none", () => {
|
||||||
|
const state: OAuthConnectionState = {
|
||||||
|
authorized: true,
|
||||||
|
protocol: "standard",
|
||||||
|
serverUrl: "https://mcp.example.com/mcp",
|
||||||
|
tokens: { access_token: "tok", token_type: "Bearer" },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(oauthDetailsFromConnectionState(state)).not.toHaveProperty(
|
||||||
|
"idToken",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("includes EMA idp session when present", () => {
|
it("includes EMA idp session when present", () => {
|
||||||
const state: OAuthConnectionState = {
|
const state: OAuthConnectionState = {
|
||||||
authorized: false,
|
authorized: false,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ export function oauthDetailsFromConnectionState(
|
||||||
...(state.tokens?.access_token && {
|
...(state.tokens?.access_token && {
|
||||||
accessToken: state.tokens.access_token,
|
accessToken: state.tokens.access_token,
|
||||||
}),
|
}),
|
||||||
|
// Surfaced for inspection when the AS returned one; omitted entirely
|
||||||
|
// otherwise so Connection Info renders no empty row (#2019).
|
||||||
|
...(state.tokens?.id_token && {
|
||||||
|
idToken: state.tokens.id_token,
|
||||||
|
}),
|
||||||
...(state.protocol === "ema" &&
|
...(state.protocol === "ema" &&
|
||||||
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
state.ema?.idpSession && { idpSession: state.ema.idpSession }),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -700,10 +700,272 @@ describe("ServerSettingsForm", () => {
|
||||||
clientId: "a",
|
clientId: "a",
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
|
authorizationParams: [],
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2018 — custom authorization-request parameters.
|
||||||
|
it("shows the empty hint for authorization parameters when none are set", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={emptySettings}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText("Additional authorization parameters"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("No additional parameters configured"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an authorization-parameter row through onOAuthChange", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOAuthChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
onOAuthChange={onOAuthChange}
|
||||||
|
settings={emptySettings}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
|
||||||
|
expect(onOAuthChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationParams: [{ key: "", value: "" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits and removes an authorization-parameter row", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOAuthChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
onOAuthChange={onOAuthChange}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const valueInput = screen.getByDisplayValue("corp");
|
||||||
|
await user.type(valueInput, "x");
|
||||||
|
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationParams: [{ key: "kc_idp_hint", value: "corpx" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onOAuthChange.mockClear();
|
||||||
|
const removeButton = screen
|
||||||
|
.getAllByRole("button")
|
||||||
|
.find((b) => b.textContent === "X");
|
||||||
|
await user.click(removeButton as HTMLElement);
|
||||||
|
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ authorizationParams: [] }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears an authorization-parameter key via its Clear button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onOAuthChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
onOAuthChange={onOAuthChange}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(clearButtonFor(screen.getByDisplayValue("kc_idp_hint")));
|
||||||
|
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationParams: [{ key: "", value: "corp" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
onOAuthChange.mockClear();
|
||||||
|
await user.click(clearButtonFor(screen.getByDisplayValue("corp")));
|
||||||
|
expect(onOAuthChange).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
authorizationParams: [{ key: "kc_idp_hint", value: "" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a reserved authorization-parameter key inline and with a warning", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'"state" is set by the authorization flow and cannot be overridden.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Reserved parameters ignored")).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue("state")).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #2018 — the section heading and the placeholders are not programmatically
|
||||||
|
// associated with these inputs, so each control carries its own aria-label and
|
||||||
|
// the reserved-key reason is the input's `error` string (which Mantine wires
|
||||||
|
// to the input via aria-describedby) rather than free-standing text.
|
||||||
|
it("gives each authorization-parameter control an accessible name", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "kc_idp_hint", value: "corp" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toHaveValue("kc_idp_hint");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter value, kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toHaveValue("corp");
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "Remove authorization parameter kc_idp_hint",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a row number in the accessible name when the key is blank", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "", value: "" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, row 1",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The reason must reach assistive tech through the input, not just as text on
|
||||||
|
// the page: Mantine emits the `error` string with an id the input references.
|
||||||
|
it("associates the reserved-key reason with the key input", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "state", value: "spoofed" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const keyInput = screen.getByRole("textbox", {
|
||||||
|
name: "Authorization parameter name, state",
|
||||||
|
});
|
||||||
|
const describedBy = keyInput.getAttribute("aria-describedby");
|
||||||
|
expect(describedBy).toBeTruthy();
|
||||||
|
const reason = describedBy
|
||||||
|
?.split(" ")
|
||||||
|
.map((id) => document.getElementById(id)?.textContent ?? "")
|
||||||
|
.join(" ");
|
||||||
|
expect(reason).toContain(
|
||||||
|
'"state" is set by the authorization flow and cannot be overridden.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #2018 — the EMA leg authorizes against the enterprise IdP, a different
|
||||||
|
// authorization server, and deliberately sends none of these parameters. The
|
||||||
|
// description has to say so, or the form shows a configured value as active
|
||||||
|
// when it will not be sent.
|
||||||
|
it("warns that authorization parameters are unused under enterprise-managed auth", () => {
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, enterpriseManaged: true }}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Not sent while Enterprise-managed authorization is on/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{ ...emptySettings, enterpriseManaged: false }}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
/Not sent while Enterprise-managed authorization is on/,
|
||||||
|
),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not flag a blank authorization-parameter row as reserved", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [{ key: "", value: "" }],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Reserved parameters ignored"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pluralizes the reserved-parameter warning for multiple keys", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsForm
|
||||||
|
{...baseHandlers}
|
||||||
|
settings={{
|
||||||
|
...emptySettings,
|
||||||
|
oauthAuthorizationParams: [
|
||||||
|
{ key: "state", value: "x" },
|
||||||
|
{ key: "scope", value: "y" },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
expandedSections={["oauth"]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/state, scope are set/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
|
it("invokes onOAuthChange with the chosen insufficient-scope policy (SEP-2350)", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onOAuthChange = vi.fn();
|
const onOAuthChange = vi.fn();
|
||||||
|
|
@ -744,6 +1006,7 @@ describe("ServerSettingsForm", () => {
|
||||||
clientId: "",
|
clientId: "",
|
||||||
clientSecret: "",
|
clientSecret: "",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
|
authorizationParams: [],
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -848,6 +1111,7 @@ describe("ServerSettingsForm", () => {
|
||||||
clientId: "",
|
clientId: "",
|
||||||
clientSecret: "z",
|
clientSecret: "z",
|
||||||
scopes: "",
|
scopes: "",
|
||||||
|
authorizationParams: [],
|
||||||
enterpriseManaged: false,
|
enterpriseManaged: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Flex,
|
Flex,
|
||||||
|
|
@ -26,6 +27,11 @@ import {
|
||||||
MODERN_LOG_LEVELS,
|
MODERN_LOG_LEVELS,
|
||||||
} from "@inspector/core/mcp/types.js";
|
} from "@inspector/core/mcp/types.js";
|
||||||
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
|
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
|
||||||
|
import {
|
||||||
|
RESERVED_AUTHORIZATION_PARAMS,
|
||||||
|
authorizationParamKeyError,
|
||||||
|
isReservedAuthorizationParam,
|
||||||
|
} from "@inspector/core/auth/authorizationParams.js";
|
||||||
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
import { ADVERTISABLE_EXTENSIONS } from "@inspector/core/mcp/extensions.js";
|
||||||
import type { Root } from "@modelcontextprotocol/client";
|
import type { Root } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
|
|
@ -184,6 +190,25 @@ const EmptyHint = Text.withProps({
|
||||||
fs: "italic",
|
fs: "italic",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Matches the label/description pair Mantine renders for a single input, so the
|
||||||
|
// authorization-parameter row list reads as one labelled field next to Scopes.
|
||||||
|
const FieldLabel = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
fw: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FieldDescription = Text.withProps({
|
||||||
|
size: "xs",
|
||||||
|
c: "dimmed",
|
||||||
|
});
|
||||||
|
|
||||||
|
// `align="flex-start"` keeps the key/value/remove controls top-aligned when the
|
||||||
|
// key input grows an error message, so the row doesn't jump as it appears.
|
||||||
|
const AuthorizationParamRow = Group.withProps({
|
||||||
|
grow: true,
|
||||||
|
align: "flex-start",
|
||||||
|
});
|
||||||
|
|
||||||
const ClearStoredOAuthButton = Button.withProps({
|
const ClearStoredOAuthButton = Button.withProps({
|
||||||
variant: "light",
|
variant: "light",
|
||||||
color: "red",
|
color: "red",
|
||||||
|
|
@ -245,6 +270,74 @@ function KeyValueRows({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reserved-key rejection is inline per row (#2018): the reason is passed as the
|
||||||
|
// key input's `error` **string**, so Mantine renders it in the input's own error
|
||||||
|
// slot and wires the `aria-describedby` association — a bare boolean would mark
|
||||||
|
// the field invalid without telling a screen reader why. The section-level Alert
|
||||||
|
// below repeats it so the rejection stays visible when the offending row is
|
||||||
|
// scrolled out of view.
|
||||||
|
//
|
||||||
|
// Each control carries a per-row `aria-label`: the section heading and the
|
||||||
|
// placeholders are not programmatically associated with the inputs, so without
|
||||||
|
// one an assistive technology cannot tell the key box from the value box, and
|
||||||
|
// the remove button announces only "X".
|
||||||
|
function AuthorizationParamRows({
|
||||||
|
params,
|
||||||
|
onChange,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
params: { key: string; value: string }[];
|
||||||
|
onChange: (index: number, key: string, value: string) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{params.map((param, index) => {
|
||||||
|
const rowLabel = param.key.trim() || `row ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<AuthorizationParamRow key={index}>
|
||||||
|
<ClearableTextInput
|
||||||
|
aria-label={`Authorization parameter name, ${rowLabel}`}
|
||||||
|
placeholder="Parameter (e.g. kc_idp_hint)"
|
||||||
|
value={param.key}
|
||||||
|
error={authorizationParamKeyError(param.key)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(index, e.currentTarget.value, param.value)
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
param.key ? (
|
||||||
|
<ClearButton
|
||||||
|
onClick={() => onChange(index, "", param.value)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ClearableTextInput
|
||||||
|
aria-label={`Authorization parameter value, ${rowLabel}`}
|
||||||
|
placeholder="Value"
|
||||||
|
value={param.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(index, param.key, e.currentTarget.value)
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
param.value ? (
|
||||||
|
<ClearButton onClick={() => onChange(index, param.key, "")} />
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RemoveIcon
|
||||||
|
aria-label={`Remove authorization parameter ${rowLabel}`}
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
>
|
||||||
|
X
|
||||||
|
</RemoveIcon>
|
||||||
|
</AuthorizationParamRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RootRows({
|
function RootRows({
|
||||||
roots,
|
roots,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -374,16 +467,52 @@ export function ServerSettingsForm({
|
||||||
onTimeoutChange(field, numValue);
|
onTimeoutChange(field, numValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #2018 — custom authorization-request parameters. The rows ride the single
|
||||||
|
// `onOAuthChange` callback with the rest of the Authorization section rather
|
||||||
|
// than growing three more props on this form.
|
||||||
|
const authorizationParams = settings.oauthAuthorizationParams ?? [];
|
||||||
|
|
||||||
function currentOAuth(): OAuthSettings {
|
function currentOAuth(): OAuthSettings {
|
||||||
return {
|
return {
|
||||||
clientId: settings.oauthClientId ?? "",
|
clientId: settings.oauthClientId ?? "",
|
||||||
clientSecret: settings.oauthClientSecret ?? "",
|
clientSecret: settings.oauthClientSecret ?? "",
|
||||||
scopes: settings.oauthScopes ?? "",
|
scopes: settings.oauthScopes ?? "",
|
||||||
|
authorizationParams,
|
||||||
enterpriseManaged: settings.enterpriseManaged ?? false,
|
enterpriseManaged: settings.enterpriseManaged ?? false,
|
||||||
onInsufficientScope: settings.oauthOnInsufficientScope,
|
onInsufficientScope: settings.oauthOnInsufficientScope,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rejectedParamKeys = authorizationParams
|
||||||
|
.map((p) => p.key.trim())
|
||||||
|
.filter((key) => isReservedAuthorizationParam(key));
|
||||||
|
|
||||||
|
function changeAuthorizationParams(
|
||||||
|
params: { key: string; value: string }[],
|
||||||
|
): void {
|
||||||
|
onOAuthChange({ ...currentOAuth(), authorizationParams: params });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddAuthorizationParam(): void {
|
||||||
|
changeAuthorizationParams([...authorizationParams, { key: "", value: "" }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveAuthorizationParam(index: number): void {
|
||||||
|
changeAuthorizationParams(
|
||||||
|
authorizationParams.filter((_, i) => i !== index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthorizationParamChange(
|
||||||
|
index: number,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
|
changeAuthorizationParams(
|
||||||
|
authorizationParams.map((p, i) => (i === index ? { key, value } : p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Stays inline: Accordion is a compound, `multiple`-discriminated generic,
|
// Stays inline: Accordion is a compound, `multiple`-discriminated generic,
|
||||||
// so `.withProps({ multiple: true, ... })` loses its JSX call signature
|
// so `.withProps({ multiple: true, ... })` loses its JSX call signature
|
||||||
|
|
@ -699,6 +828,41 @@ export function ServerSettingsForm({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<FieldLabel>Additional authorization parameters</FieldLabel>
|
||||||
|
<FieldDescription>
|
||||||
|
Extra query parameters appended to the authorization request
|
||||||
|
only — never the token request. Use them for provider-specific
|
||||||
|
hints such as kc_idp_hint, login_hint, prompt, acr_values, or
|
||||||
|
audience.
|
||||||
|
{settings.enterpriseManaged
|
||||||
|
? " Not sent while Enterprise-managed authorization is on: that flow authorizes against the enterprise IdP, a different authorization server."
|
||||||
|
: ""}
|
||||||
|
</FieldDescription>
|
||||||
|
{authorizationParams.length === 0 ? (
|
||||||
|
<EmptyHint>No additional parameters configured</EmptyHint>
|
||||||
|
) : (
|
||||||
|
<AuthorizationParamRows
|
||||||
|
params={authorizationParams}
|
||||||
|
onChange={handleAuthorizationParamChange}
|
||||||
|
onRemove={handleRemoveAuthorizationParam}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rejectedParamKeys.length > 0 ? (
|
||||||
|
<Alert color="red" title="Reserved parameters ignored">
|
||||||
|
{`${rejectedParamKeys.join(", ")} ${
|
||||||
|
rejectedParamKeys.length === 1 ? "is" : "are"
|
||||||
|
} set by the authorization flow and will not be sent. Overriding ${
|
||||||
|
rejectedParamKeys.length === 1 ? "it" : "them"
|
||||||
|
} would break PKCE, the CSRF state binding, or the resource indicator. Reserved: ${RESERVED_AUTHORIZATION_PARAMS.join(
|
||||||
|
", ",
|
||||||
|
)}.`}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<AddButton onClick={handleAddAuthorizationParam}>
|
||||||
|
+ Add Parameter
|
||||||
|
</AddButton>
|
||||||
|
</Stack>
|
||||||
<Select
|
<Select
|
||||||
label="Insufficient-scope response"
|
label="Insufficient-scope response"
|
||||||
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."
|
description="On a 403 insufficient_scope challenge (SEP-2350): re-authorize with the accumulated scope union, or surface the error to you."
|
||||||
|
|
|
||||||
|
|
@ -440,10 +440,35 @@ describe("ServerSettingsModal", () => {
|
||||||
oauthClientId: "",
|
oauthClientId: "",
|
||||||
oauthClientSecret: "",
|
oauthClientSecret: "",
|
||||||
oauthScopes: "",
|
oauthScopes: "",
|
||||||
|
oauthAuthorizationParams: [],
|
||||||
enterpriseManaged: true,
|
enterpriseManaged: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #2018 — the authorization-parameter rows ride the same onOAuthChange
|
||||||
|
// callback, so the modal folds them onto the settings object.
|
||||||
|
it("persists an added authorization-parameter row onto the settings", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSettingsChange = vi.fn();
|
||||||
|
renderWithMantine(
|
||||||
|
<ServerSettingsModal
|
||||||
|
opened
|
||||||
|
settings={emptySettings}
|
||||||
|
serverType="streamable-http"
|
||||||
|
isStdio={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "OAuth Settings" }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "+ Add Parameter" }));
|
||||||
|
expect(onSettingsChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
oauthAuthorizationParams: [{ key: "", value: "" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("calls onSettingsChange with a blank row when adding a root", async () => {
|
it("calls onSettingsChange with a blank row when adding a root", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSettingsChange = vi.fn();
|
const onSettingsChange = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,10 @@ export function ServerSettingsModal({
|
||||||
oauthClientId: oauth.clientId,
|
oauthClientId: oauth.clientId,
|
||||||
oauthClientSecret: oauth.clientSecret,
|
oauthClientSecret: oauth.clientSecret,
|
||||||
oauthScopes: oauth.scopes,
|
oauthScopes: oauth.scopes,
|
||||||
|
// #2018: kept as rows (blank ones included) so a half-typed parameter
|
||||||
|
// survives a re-render; the drop-blank/omit-empty filtering happens on the
|
||||||
|
// way to disk. An emptied list persists as `[]`, which writes nothing.
|
||||||
|
oauthAuthorizationParams: oauth.authorizationParams,
|
||||||
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
|
||||||
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
// SEP-2350: persist only the non-default ('throw') so unset servers keep
|
||||||
// the SDK's `reauthorize` behavior without writing a spurious field.
|
// the SDK's `reauthorize` behavior without writing a spurious field.
|
||||||
|
|
|
||||||
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 { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/client";
|
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 type { EmaFlowConfig } from "@inspector/core/auth/ema/emaFlow.js";
|
||||||
import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js";
|
import { EmaTransportOAuthProvider } from "@inspector/core/auth/ema/transportProvider.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,6 +26,20 @@ const startIdpMock = vi.mocked(startEmaIdpAuthorization);
|
||||||
|
|
||||||
const SERVER_URL = "http://127.0.0.1:9999/mcp";
|
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 {
|
function jwtWithExp(expSec: number): string {
|
||||||
const payload = btoa(JSON.stringify({ exp: expSec }))
|
const payload = btoa(JSON.stringify({ exp: expSec }))
|
||||||
.replace(/\+/g, "-")
|
.replace(/\+/g, "-")
|
||||||
|
|
@ -39,6 +61,7 @@ interface FakeInner {
|
||||||
tokens: ReturnType<typeof vi.fn>;
|
tokens: ReturnType<typeof vi.fn>;
|
||||||
saveTokens: ReturnType<typeof vi.fn>;
|
saveTokens: ReturnType<typeof vi.fn>;
|
||||||
redirectToAuthorization: ReturnType<typeof vi.fn>;
|
redirectToAuthorization: ReturnType<typeof vi.fn>;
|
||||||
|
redirectToExternalAuthorization: ReturnType<typeof vi.fn>;
|
||||||
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
|
clearCapturedAuthUrl: ReturnType<typeof vi.fn>;
|
||||||
saveCodeVerifier: ReturnType<typeof vi.fn>;
|
saveCodeVerifier: ReturnType<typeof vi.fn>;
|
||||||
codeVerifier: ReturnType<typeof vi.fn>;
|
codeVerifier: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -55,6 +78,7 @@ function createInner(): FakeInner {
|
||||||
tokens: vi.fn(),
|
tokens: vi.fn(),
|
||||||
saveTokens: vi.fn(),
|
saveTokens: vi.fn(),
|
||||||
redirectToAuthorization: vi.fn(),
|
redirectToAuthorization: vi.fn(),
|
||||||
|
redirectToExternalAuthorization: vi.fn(),
|
||||||
clearCapturedAuthUrl: vi.fn(),
|
clearCapturedAuthUrl: vi.fn(),
|
||||||
saveCodeVerifier: vi.fn(),
|
saveCodeVerifier: vi.fn(),
|
||||||
codeVerifier: vi.fn(() => "verifier-xyz"),
|
codeVerifier: vi.fn(() => "verifier-xyz"),
|
||||||
|
|
@ -178,6 +202,43 @@ describe("EmaTransportOAuthProvider", () => {
|
||||||
|
|
||||||
expect(startIdpMock).toHaveBeenCalledWith(emaConfig);
|
expect(startIdpMock).toHaveBeenCalledWith(emaConfig);
|
||||||
expect(inner.clearCapturedAuthUrl).toHaveBeenCalledTimes(1);
|
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);
|
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 () => {
|
it("delegates token and scope persistence to storage", async () => {
|
||||||
const storage = makeStorage();
|
const storage = makeStorage();
|
||||||
const provider = makeProvider(storage);
|
const provider = makeProvider(storage);
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,44 @@ describe("runner client auth options", () => {
|
||||||
expect(opts.oauth?.clientId).toBe("resource-client");
|
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", () => {
|
it("buildRunnerClientAuthOptions returns no oauth when nothing supplies it", () => {
|
||||||
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
expect(buildRunnerClientAuthOptions({})).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue