Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # AGENTS.md # clients/cli/README.md # clients/web/src/App.test.tsx # clients/web/src/App.tsx # clients/web/src/test/core/auth/utils.test.ts # core/mcp/node/transport.ts # test-servers/src/composable-test-server.ts
This commit is contained in:
commit
cfbdfdf2bd
4 changed files with 15921 additions and 1202 deletions
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ReAuthBanner } from "./ReAuthBanner";
|
||||
|
||||
describe("ReAuthBanner", () => {
|
||||
it("renders message and action buttons", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onReauthenticate = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
|
||||
renderWithMantine(
|
||||
<ReAuthBanner
|
||||
message="Sign in again to continue."
|
||||
onReauthenticate={onReauthenticate}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Re-authentication required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sign in again to continue.")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Re-authenticate" }));
|
||||
expect(onReauthenticate).toHaveBeenCalledOnce();
|
||||
|
||||
const [, closeButton] = screen.getAllByRole("button");
|
||||
await user.click(closeButton);
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Alert, Button, Group, Text } from "@mantine/core";
|
||||
|
||||
export interface ReAuthBannerProps {
|
||||
message: string;
|
||||
onReauthenticate: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ReAuthBanner({
|
||||
message,
|
||||
onReauthenticate,
|
||||
onDismiss,
|
||||
}: ReAuthBannerProps) {
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="reauth"
|
||||
title="Re-authentication required"
|
||||
withCloseButton
|
||||
onClose={onDismiss}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
|
||||
<Text component="span" size="sm">
|
||||
{message}
|
||||
</Text>
|
||||
<Button size="xs" variant="filled" onClick={onReauthenticate}>
|
||||
Re-authenticate
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { StepUpAuthModal } from "./StepUpAuthModal";
|
||||
|
||||
const stepUpChallenge: AuthChallenge = {
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
authorizationScopes: ["mcp", "tools:read", "weather:read"],
|
||||
context: { toolName: "get_temp" },
|
||||
};
|
||||
|
||||
describe("StepUpAuthModal", () => {
|
||||
it("does not render when closed", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened={false}
|
||||
challenge={stepUpChallenge}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/Additional permissions required/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists additional scopes from the challenge, not the SEP-2350 union", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
challenge={stepUpChallenge}
|
||||
authorizationScopes={stepUpChallenge.authorizationScopes}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Additional permissions required/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/get_temp/)).toBeInTheDocument();
|
||||
expect(screen.getByText("weather:read")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools:read")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("mcp")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/redirected to authorize, then returned to the inspector/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses EMA copy when enterpriseManaged is true", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
enterpriseManaged
|
||||
challenge={stepUpChallenge}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Additional organization permissions required/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/enterprise identity provider/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
/redirected to authorize, then returned to the inspector/i,
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back to requiredScopes when authorizationScopes is empty", () => {
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
challenge={{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["admin:write"],
|
||||
}}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("admin:write")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onAuthorize when Authorize is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAuthorize = vi.fn();
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
challenge={stepUpChallenge}
|
||||
onAuthorize={onAuthorize}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Authorize$/ }));
|
||||
expect(onAuthorize).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onCancel when Cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
renderWithMantine(
|
||||
<StepUpAuthModal
|
||||
opened
|
||||
challenge={stepUpChallenge}
|
||||
onAuthorize={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /^Cancel$/ }));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import { Button, Group, List, Modal, Stack, Text } from "@mantine/core";
|
||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
stepUpAdditionalScopes,
|
||||
stepUpConfirmMessage,
|
||||
stepUpFollowUpMessage,
|
||||
stepUpModalTitle,
|
||||
} from "@inspector/core/auth/oauthUx.js";
|
||||
|
||||
export interface StepUpAuthModalProps {
|
||||
opened: boolean;
|
||||
challenge: AuthChallenge | null;
|
||||
/** Effective consent scope set (SEP-2350 union). */
|
||||
authorizationScopes?: string[];
|
||||
/** Enterprise-managed (EMA) server — organization/IdP copy instead of resource AS. */
|
||||
enterpriseManaged?: boolean;
|
||||
onAuthorize: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const Actions = Group.withProps({ justify: "flex-end", gap: "sm", mt: "md" });
|
||||
|
||||
export function StepUpAuthModal({
|
||||
opened,
|
||||
challenge,
|
||||
authorizationScopes,
|
||||
enterpriseManaged,
|
||||
onAuthorize,
|
||||
onCancel,
|
||||
}: StepUpAuthModalProps) {
|
||||
const additionalScopes = challenge ? stepUpAdditionalScopes(challenge) : [];
|
||||
const ema = enterpriseManaged === true;
|
||||
const handleAuthorize = () => void onAuthorize();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
size="md"
|
||||
centered
|
||||
title={stepUpModalTitle({ enterpriseManaged: ema })}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{challenge
|
||||
? stepUpConfirmMessage(challenge, { enterpriseManaged: ema })
|
||||
: stepUpConfirmMessage(
|
||||
{ reason: "insufficient_scope" },
|
||||
{ enterpriseManaged: ema },
|
||||
)}{" "}
|
||||
{stepUpFollowUpMessage({ enterpriseManaged: ema })}
|
||||
</Text>
|
||||
{additionalScopes.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{ema
|
||||
? "Additional permissions needed"
|
||||
: "Additional scopes needed"}
|
||||
</Text>
|
||||
<List size="sm" spacing="xs">
|
||||
{additionalScopes.map((scope) => (
|
||||
<List.Item key={scope}>
|
||||
<Text component="span" ff="monospace" size="sm">
|
||||
{scope}
|
||||
</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
{!ema &&
|
||||
authorizationScopes &&
|
||||
authorizationScopes.length > additionalScopes.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
The authorization server may also show scopes you already
|
||||
granted during sign-in.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
<Actions>
|
||||
<Button variant="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAuthorize}>Authorize</Button>
|
||||
</Actions>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue