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
4 changed files with 7585 additions and 595 deletions

View file

@ -0,0 +1,74 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsForm } from "./ClientSettingsForm";
import { EMPTY_CLIENT_SETTINGS } from "./clientSettingsValues";
describe("ClientSettingsForm EMA IdP session", () => {
it("shows signed-in state and sign out", async () => {
const user = userEvent.setup();
const onLogout = vi.fn();
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="logged_in"
onEmaIdpLogout={onLogout}
/>,
);
expect(screen.getByText("Signed in")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Sign out" }));
expect(onLogout).toHaveBeenCalled();
});
it("shows not signed in without sign out button", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
onEmaIdpLogout={vi.fn()}
/>,
);
expect(screen.getByText(/Not signed in/i)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Sign out" }),
).not.toBeInTheDocument();
});
it("shows expired state with sign out", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
issuer: "https://idp.test",
}}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="expired"
onEmaIdpLogout={vi.fn()}
/>,
);
expect(screen.getByText("Session expired")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Sign out" }),
).toBeInTheDocument();
});
});

View file

@ -0,0 +1,174 @@
import {
Accordion,
Badge,
Button,
Checkbox,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ClearButton } from "../../elements/ClearButton/ClearButton";
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
import type { ClientSettingsFormValues } from "./clientSettingsValues.js";
export type ClientSettingsSection = "ema";
export interface ClientSettingsFormProps {
settings: ClientSettingsFormValues;
expandedSections: ClientSettingsSection[];
onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
}
const HintText = Text.withProps({
size: "sm",
c: "dimmed",
});
export function ClientSettingsForm({
settings,
expandedSections,
onExpandedSectionsChange,
onSettingsChange,
emaIdpLoginState = "none",
onEmaIdpLogout,
}: ClientSettingsFormProps) {
function patch(partial: Partial<ClientSettingsFormValues>) {
onSettingsChange({ ...settings, ...partial });
}
const showIdpSession =
settings.emaEnabled &&
settings.issuer.trim() !== "" &&
emaIdpLoginState !== "none";
return (
<Accordion
multiple
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ClientSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="ema">
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Enable enterprise IdP configuration"
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
checked={settings.emaEnabled}
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
/>
{settings.emaEnabled && (
<>
<HintText>
Register this redirect URI with your IdP:{" "}
{typeof window !== "undefined"
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
label="Issuer"
description="Your enterprise IdP issuer URL."
value={settings.issuer}
onChange={(e) => patch({ issuer: e.currentTarget.value })}
rightSectionPointerEvents="auto"
rightSection={
settings.issuer ? (
<ClearButton onClick={() => patch({ issuer: "" })} />
) : null
}
/>
<TextInput
label="Client ID"
description="Client id registered with your enterprise IdP."
value={settings.clientId}
onChange={(e) => patch({ clientId: e.currentTarget.value })}
rightSectionPointerEvents="auto"
rightSection={
settings.clientId ? (
<ClearButton onClick={() => patch({ clientId: "" })} />
) : null
}
/>
<TextInput
label="Client Secret"
description="Client secret for your enterprise IdP (if required)."
type="password"
value={settings.clientSecret}
onChange={(e) =>
patch({ clientSecret: e.currentTarget.value })
}
rightSectionPointerEvents="auto"
rightSection={
settings.clientSecret ? (
<ClearButton
onClick={() => patch({ clientSecret: "" })}
/>
) : null
}
/>
{settings.issuer.trim() !== "" && (
<Group
justify="space-between"
align="flex-start"
wrap="nowrap"
>
<Stack gap={4} flex={1}>
<Text size="sm" fw={500}>
IdP sign-in
</Text>
{emaIdpLoginState === "logged_in" ? (
<>
<Badge w="fit-content" color="green" variant="light">
Signed in
</Badge>
<HintText>
Your enterprise IdP session is active. Connecting to
EMA-enabled MCP servers will not prompt for IdP
login until you sign out or the session expires.
</HintText>
</>
) : emaIdpLoginState === "expired" ? (
<>
<Badge w="fit-content" color="yellow" variant="light">
Session expired
</Badge>
<HintText>
Your cached IdP session has expired. The next
connect to an EMA-enabled server will prompt for IdP
login.
</HintText>
</>
) : (
<HintText>
Not signed in to your enterprise IdP. Connecting to an
EMA-enabled MCP server will open IdP login.
</HintText>
)}
</Stack>
{showIdpSession && onEmaIdpLogout ? (
<Button
variant="light"
color="red"
size="compact-sm"
onClick={onEmaIdpLogout}
>
Sign out
</Button>
) : null}
</Group>
)}
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View file

@ -0,0 +1,125 @@
import { describe, it, expect } from "vitest";
import {
canPersistClientSettingsDraft,
clientConfigToFormValues,
formValuesToClientConfig,
} from "./clientSettingsValues";
describe("clientSettingsValues", () => {
it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => {
expect(
clientConfigToFormValues({
enterpriseManagedAuth: {
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
}),
).toEqual({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
});
});
it("clientConfigToFormValues preserves IdP fields when EMA is disabled", () => {
expect(
clientConfigToFormValues({
enterpriseManagedAuth: {
enabled: false,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
}),
).toEqual({
emaEnabled: false,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
});
});
it("formValuesToClientConfig keeps IdP credentials when disabled", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
}),
).toEqual({
enterpriseManagedAuth: {
enabled: false,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
});
});
it("formValuesToClientConfig omits block when disabled with no stored fields", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
}),
).toEqual({});
});
it("formValuesToClientConfig trims issuer and clientId when enabled", () => {
expect(
formValuesToClientConfig({
emaEnabled: true,
issuer: " https://idp.test ",
clientId: " cid ",
clientSecret: "sec",
}),
).toEqual({
enterpriseManagedAuth: {
enabled: true,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
},
},
});
});
it("canPersistClientSettingsDraft allows disabled or complete IdP fields", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
}),
).toBe(true);
expect(
canPersistClientSettingsDraft({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
}),
).toBe(true);
expect(
canPersistClientSettingsDraft({
emaEnabled: true,
issuer: "",
clientId: "cid",
clientSecret: "",
}),
).toBe(false);
});
});

View file

@ -0,0 +1,69 @@
import type { ClientConfig } from "@inspector/core/client/types.js";
/** Form shape for install-level client settings (client.json). */
export interface ClientSettingsFormValues {
emaEnabled: boolean;
issuer: string;
clientId: string;
clientSecret: string;
}
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
};
export function clientConfigToFormValues(
config: ClientConfig,
): ClientSettingsFormValues {
const ema = config.enterpriseManagedAuth;
const idp = ema?.idp;
if (!idp) {
return { ...EMPTY_CLIENT_SETTINGS };
}
return {
emaEnabled: ema.enabled !== false,
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret ?? "",
};
}
function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
return (
values.issuer.trim() !== "" ||
values.clientId.trim() !== "" ||
values.clientSecret !== ""
);
}
export function formValuesToClientConfig(
values: ClientSettingsFormValues,
): ClientConfig {
if (!hasStoredIdpFields(values)) {
return {};
}
const idp = {
issuer: values.issuer.trim(),
clientId: values.clientId.trim(),
clientSecret: values.clientSecret,
};
return {
enterpriseManagedAuth: {
enabled: values.emaEnabled,
idp,
},
};
}
/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */
export function canPersistClientSettingsDraft(
values: ClientSettingsFormValues,
): boolean {
if (!values.emaEnabled) return true;
return values.issuer.trim() !== "" && values.clientId.trim() !== "";
}