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:
parent
5ee347571f
commit
5de4e911e3
4 changed files with 7585 additions and 595 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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() !== "";
|
||||
}
|
||||
Loading…
Reference in a new issue