feat(web,core): enterprise-managed authorization (EMA) (#1509) (#1512)

Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
Bob Dickinson 2026-06-24 19:15:12 -07:00 • committed by GitHub
commit 5de4e911e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 7585 additions and 595 deletions

View file

@ -53,6 +53,7 @@ const meta: Meta<typeof ServerSettingsModal> = {
render: InteractiveRender,
args: {
opened: true,
serverType: "streamable-http",
isStdio: false,
onClose: fn(),
onSettingsChange: fn(),
@ -87,6 +88,7 @@ export const EmptySettings: Story = {
// Environment Variables section.
export const StdioServer: Story = {
args: {
serverType: "stdio",
isStdio: true,
settings: {
headers: [],

View file

@ -35,6 +35,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened={false}
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
@ -48,6 +49,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
@ -57,6 +59,20 @@ describe("ServerSettingsModal", () => {
expect(screen.getByText("Custom Headers")).toBeInTheDocument();
});
it("hides the OAuth Settings section for stdio servers", () => {
renderWithMantine(
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
@ -64,6 +80,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={onClose}
onSettingsChange={vi.fn()}
@ -85,6 +102,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -105,6 +123,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -126,6 +145,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -148,6 +168,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -168,6 +189,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -191,6 +213,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -212,6 +235,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -232,6 +256,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -245,6 +270,34 @@ describe("ServerSettingsModal", () => {
expect(call.oauthClientId).toBe("a");
});
it("calls onSettingsChange when toggling enterprise-managed authorization", 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("checkbox", {
name: "Enterprise-managed authorization",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,
oauthClientId: "",
oauthClientSecret: "",
oauthScopes: "",
enterpriseManaged: true,
});
});
it("calls onSettingsChange with a blank row when adding a root", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
@ -252,6 +305,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -272,6 +326,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -294,6 +349,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={initialSettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -315,6 +371,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
@ -359,6 +416,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="streamable-http"
isStdio={false}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
@ -376,6 +434,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={emptySettings}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -401,6 +460,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -424,6 +484,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={{ ...emptySettings, env: [{ key: "A", value: "1" }] }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
@ -444,6 +505,7 @@ describe("ServerSettingsModal", () => {
<ServerSettingsModal
opened
settings={{ ...emptySettings, cwd: "/srv" }}
serverType="stdio"
isStdio
onClose={vi.fn()}
onSettingsChange={onSettingsChange}

View file

@ -3,24 +3,30 @@ import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
import type {
InspectorServerSettings,
OAuthSettings,
ServerType,
} from "@inspector/core/mcp/types.js";
import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ServerSettingsForm,
type ServerSettingsSection,
} from "../ServerSettingsForm/ServerSettingsForm";
// The "environment" section only renders for stdio servers, so it joins the
// expand/collapse-all set only then — otherwise `allExpanded` could never be
// reached (the section isn't in the DOM to expand).
function allSectionsFor(isStdio: boolean): ServerSettingsSection[] {
// The "environment" section only renders for stdio servers and the "oauth"
// section only for OAuth-capable transports, so each joins the
// expand/collapse-all set only when present — otherwise `allExpanded` could
// never be reached (a section not in the DOM can't be expanded).
function allSectionsFor(
serverType: ServerType,
isStdio: boolean,
): ServerSettingsSection[] {
return [
"options",
...(isStdio ? (["environment"] as const) : []),
"headers",
"metadata",
"timeouts",
"oauth",
...(isOAuthCapableServerType(serverType) ? (["oauth"] as const) : []),
"roots",
];
}
@ -28,6 +34,7 @@ function allSectionsFor(isStdio: boolean): ServerSettingsSection[] {
export interface ServerSettingsModalProps {
opened: boolean;
settings: InspectorServerSettings;
serverType: ServerType;
/**
* Whether the target server uses the stdio transport. Forwarded to the form
* to gate the stdio-only Working Directory field and Environment Variables
@ -41,10 +48,12 @@ export interface ServerSettingsModalProps {
export function ServerSettingsModal({
opened,
settings,
serverType,
isStdio,
onClose,
onSettingsChange,
}: ServerSettingsModalProps) {
const sections = allSectionsFor(serverType, isStdio);
// Initial expansion is the first ("options") section — where Network Log
// Size lives, so a deep-link from the body-dropped toast lands on the
// relevant control. The parent remounts this modal per open (via `key`), so
@ -54,11 +63,10 @@ export function ServerSettingsModal({
ServerSettingsSection[]
>(["options"]);
const allSections = allSectionsFor(isStdio);
const allExpanded = expandedSections.length === allSections.length;
const allExpanded = expandedSections.length === sections.length;
function handleToggleAll() {
setExpandedSections(allExpanded ? [] : allSections);
setExpandedSections(allExpanded ? [] : sections);
}
function handleAddHeader() {
@ -139,6 +147,7 @@ export function ServerSettingsModal({
oauthClientId: oauth.clientId,
oauthClientSecret: oauth.clientSecret,
oauthScopes: oauth.scopes,
enterpriseManaged: oauth.enterpriseManaged ? true : undefined,
});
}
@ -195,6 +204,7 @@ export function ServerSettingsModal({
</Group>
<ServerSettingsForm
settings={settings}
serverType={serverType}
isStdio={isStdio}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}