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

@ -0,0 +1,77 @@
import { AppShell } from "@mantine/core";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { useArgs } from "storybook/preview-api";
import {
ClientSettingsModal,
type ClientSettingsModalProps,
} from "./ClientSettingsModal";
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues";
const configuredSettings: ClientSettingsFormValues = {
emaEnabled: true,
issuer: "https://idp.example.com",
clientId: "inspector-idp-client",
clientSecret: "super-secret-idp-value",
};
function InteractiveRender(args: ClientSettingsModalProps) {
const [, updateArgs] = useArgs<ClientSettingsModalProps>();
return (
<AppShell>
<AppShell.Main>
<ClientSettingsModal
{...args}
onSettingsChange={(settings) => {
args.onSettingsChange(settings);
updateArgs({ settings });
}}
/>
</AppShell.Main>
</AppShell>
);
}
const meta: Meta<typeof ClientSettingsModal> = {
title: "Groups/ClientSettingsModal",
component: ClientSettingsModal,
parameters: { layout: "fullscreen" },
render: InteractiveRender,
args: {
opened: true,
onClose: fn(),
onSettingsChange: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ClientSettingsModal>;
export const Configured: Story = {
args: {
settings: configuredSettings,
},
};
export const Empty: Story = {
args: {
settings: {
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
},
},
};
export const EnabledEmptyFields: Story = {
args: {
settings: {
emaEnabled: true,
issuer: "",
clientId: "",
clientSecret: "",
},
},
};

View file

@ -0,0 +1,90 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsModal } from "./ClientSettingsModal";
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
describe("ClientSettingsModal", () => {
it("renders the title when opened", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByText("Client Settings")).toBeInTheDocument();
});
it("invokes onClose when the CloseButton is clicked", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalled();
});
it("calls onSettingsChange when toggling EMA enable", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={EMPTY_CLIENT_SETTINGS}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("checkbox", {
name: "Enable enterprise IdP configuration",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
});
});
it("shows IdP fields when EMA is enabled", () => {
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={vi.fn()}
/>,
);
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
expect(screen.getByLabelText("Client ID")).toBeInTheDocument();
expect(screen.getByLabelText("Client Secret")).toBeInTheDocument();
});
it("calls onSettingsChange when typing issuer", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
onClose={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.type(screen.getByLabelText("Issuer"), "h");
expect(onSettingsChange).toHaveBeenCalled();
const call =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(call.issuer).toBe("h");
});
});

View file

@ -0,0 +1,71 @@
import { useState } from "react";
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ClientSettingsForm,
type ClientSettingsSection,
} from "../ClientSettingsForm/ClientSettingsForm";
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
export interface ClientSettingsModalProps {
opened: boolean;
settings: ClientSettingsFormValues;
onClose: () => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
}
export function ClientSettingsModal({
opened,
settings,
onClose,
onSettingsChange,
emaIdpLoginState,
onEmaIdpLogout,
}: ClientSettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<
ClientSettingsSection[]
>(["ema"]);
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
function handleToggleAll() {
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
}
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton={false}
size="lg"
centered
>
<Stack gap="md">
<Group justify="space-between" wrap="nowrap">
<ListToggle
compact={!allExpanded}
variant="subtle"
onToggle={handleToggleAll}
/>
<Title order={4} ta="center" flex={1}>
Client Settings
</Title>
<CloseButton onClick={onClose} />
</Group>
<ClientSettingsForm
settings={settings}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
onSettingsChange={onSettingsChange}
emaIdpLoginState={emaIdpLoginState}
onEmaIdpLogout={onEmaIdpLogout}
/>
</Stack>
</Modal>
);
}