mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
feat(ui): add the enterprise-managed authorization (ID-JAG) MCP form arm and auto-connected Apps grid state
This commit is contained in:
parent
58ff0e32ba
commit
19e9a31a52
9 changed files with 532 additions and 58 deletions
|
|
@ -0,0 +1,157 @@
|
|||
import React, { useState } from "react";
|
||||
import { Form, Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
interface IdJagFormFieldsProps {
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500";
|
||||
|
||||
const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, tooltip }) => (
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
{label}
|
||||
<Tooltip title={tooltip}>
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false }) => {
|
||||
const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : "";
|
||||
const [clientAuthMethod, setClientAuthMethod] = useState<"client_secret" | "private_key_jwt">("client_secret");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="IdP Token Exchange Endpoint"
|
||||
tooltip="Your enterprise identity provider's token endpoint (the organization authorization server). The gateway exchanges the user's SSO identity assertion here for an ID-JAG (RFC 8693 with requested_token_type id-jag)."
|
||||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
rules={[{ required: !isEditing, message: "The IdP token exchange endpoint is required for ID-JAG" }]}
|
||||
>
|
||||
<Input placeholder="https://your-org.okta.com/oauth2/v1/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Token Endpoint (optional)"
|
||||
tooltip="The upstream MCP server's authorization server token endpoint, where the ID-JAG is redeemed for the access token (RFC 7523 jwt-bearer). Leave blank to auto-discover it from the upstream's protected-resource metadata (RFC 9728 then RFC 8414); discovery only trusts an authorization server that advertises the id-jag grant profile."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
>
|
||||
<Input placeholder="https://mcp-as.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client ID"
|
||||
tooltip="The gateway's OAuth client ID. It must be registered at BOTH the identity provider and the upstream's authorization server, and the ID-JAG's client_id claim must match it."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Authentication"
|
||||
tooltip="How the gateway authenticates to both token endpoints: a shared client secret, or a private key signing a JWT client assertion (private_key_jwt)."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Select<"client_secret" | "private_key_jwt">
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
value={clientAuthMethod}
|
||||
onChange={setClientAuthMethod}
|
||||
options={[
|
||||
{ value: "client_secret", label: <span className="font-medium">Client Secret</span> },
|
||||
{ value: "private_key_jwt", label: <span className="font-medium">Private Key JWT</span> },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{clientAuthMethod === "client_secret" && (
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Secret"
|
||||
tooltip="OAuth2 client secret used to authenticate to both token endpoints."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
rules={[{ required: !isEditing, message: "Client Secret is required for ID-JAG" }]}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{clientAuthMethod === "private_key_jwt" && (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client Private Key"
|
||||
tooltip="PEM private key used to sign the JWT client assertion (private_key_jwt). Stored encrypted at rest."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key"]}
|
||||
rules={[{ required: !isEditing, message: "A private key is required for private_key_jwt" }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder={`-----BEGIN PRIVATE KEY-----${placeholderSuffix}`}
|
||||
className={fieldClassName}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Key ID (optional)"
|
||||
tooltip="The kid header for the client assertion, when the authorization server needs one to select the verification key."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key_id"]}
|
||||
>
|
||||
<Input placeholder="key-2026-01" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Signing Algorithm (optional)"
|
||||
tooltip="JWS algorithm for the client assertion. Defaults to RS256."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "client_assertion_signing_alg"]}
|
||||
>
|
||||
<Input placeholder="RS256" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="The upstream authorization server's identifier, sent as the RFC 8693 audience so the IdP mints the ID-JAG for it."
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
>
|
||||
<Input placeholder="https://mcp-as.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Scopes requested in the ID-JAG exchange." />}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdJagFormFields;
|
||||
|
|
@ -1058,6 +1058,128 @@ describe("CreateMCPServer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("routes Enterprise-Managed Authorization (ID-JAG) config to the backend payload", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
fireEvent.change(getServerNameInput(), { target: { value: "EMA_Server" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), {
|
||||
target: { value: "https://upstream.example.com/mcp" },
|
||||
});
|
||||
|
||||
await selectAntOption("Authentication", "Enterprise-Managed Authorization (ID-JAG)");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token"), {
|
||||
target: { value: "https://org.example.com/oauth2/v1/token" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("https://mcp-as.example.com/oauth2/token"), {
|
||||
target: { value: "https://ras.example.com/oauth2/token" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), {
|
||||
target: { value: "ema-client-id" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), {
|
||||
target: { value: "ema-client-secret" },
|
||||
});
|
||||
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-ema",
|
||||
server_name: "EMA_Server",
|
||||
alias: "EMA_Server",
|
||||
url: "https://upstream.example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "oauth2_id_jag",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
});
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "Add MCP Server" });
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
|
||||
expect(payload.auth_type).toBe("oauth2_id_jag");
|
||||
expect(payload.token_exchange_endpoint).toBe("https://org.example.com/oauth2/v1/token");
|
||||
expect(payload.credentials).toMatchObject({
|
||||
client_id: "ema-client-id",
|
||||
client_secret: "ema-client-secret",
|
||||
id_jag_resource_token_endpoint: "https://ras.example.com/oauth2/token",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes ID-JAG private_key_jwt client auth into nested credentials", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
fireEvent.change(getServerNameInput(), { target: { value: "EMA_PK_Server" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), {
|
||||
target: { value: "https://upstream.example.com/mcp" },
|
||||
});
|
||||
|
||||
await selectAntOption("Authentication", "Enterprise-Managed Authorization (ID-JAG)");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token"), {
|
||||
target: { value: "https://org.example.com/oauth2/v1/token" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), {
|
||||
target: { value: "ema-client-id" },
|
||||
});
|
||||
|
||||
await selectAntOption("Client Authentication", "Private Key JWT");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("-----BEGIN PRIVATE KEY-----")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByPlaceholderText("Enter OAuth client secret")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("-----BEGIN PRIVATE KEY-----"), {
|
||||
target: { value: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("key-2026-01"), {
|
||||
target: { value: "kid-1" },
|
||||
});
|
||||
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-ema-pk",
|
||||
server_name: "EMA_PK_Server",
|
||||
alias: "EMA_PK_Server",
|
||||
url: "https://upstream.example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "oauth2_id_jag",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add MCP Server" }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(networking.createMCPServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(networking.createMCPServer).mock.calls[0];
|
||||
expect(payload.auth_type).toBe("oauth2_id_jag");
|
||||
expect(payload.credentials).toMatchObject({
|
||||
client_id: "ema-client-id",
|
||||
client_private_key: expect.stringContaining("BEGIN PRIVATE KEY"),
|
||||
client_private_key_id: "kid-1",
|
||||
});
|
||||
expect(payload.credentials.client_secret).toBeUndefined();
|
||||
});
|
||||
|
||||
it("makes scope required when the Entra OBO profile is selected", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import OAuthFormFields from "./OAuthFormFields";
|
|||
import TruePassthroughWarning from "./TruePassthroughWarning";
|
||||
import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
||||
import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
||||
import IdJagFormFields from "./IdJagFormFields";
|
||||
import MCPServerCostConfig from "./mcp_server_cost_config";
|
||||
import MCPConnectionStatus from "./mcp_connection_status";
|
||||
import MCPToolConfiguration from "./mcp_tool_configuration";
|
||||
|
|
@ -61,6 +62,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
|
|||
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
|
||||
AUTH_TYPE.OAUTH2,
|
||||
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
|
||||
AUTH_TYPE.OAUTH2_ID_JAG,
|
||||
AUTH_TYPE.AWS_SIGV4,
|
||||
AUTH_TYPE.TRUE_PASSTHROUGH,
|
||||
AUTH_TYPE.OAUTH_DELEGATE,
|
||||
|
|
@ -140,6 +142,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
|
||||
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
|
||||
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
|
||||
const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
|
||||
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
|
||||
const isM2MFlow = isOAuthAuthType && formValues.oauth_flow_type === OAUTH_FLOW.M2M;
|
||||
|
||||
|
|
@ -1071,7 +1074,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
children: (
|
||||
<>
|
||||
<Form.Item name="auth_type" rules={[{ required: true, message: "Please select an auth type" }]}>
|
||||
<Select placeholder="Select auth type" className="rounded-lg" size="large">
|
||||
<Select placeholder="Select auth type" className="rounded-lg" size="large" virtual={false}>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
|
|
@ -1079,6 +1082,9 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="oauth2_id_jag">
|
||||
Enterprise-Managed Authorization (ID-JAG)
|
||||
</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
|
|
@ -1144,6 +1150,8 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
)}
|
||||
|
||||
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
|
||||
|
||||
{isIdJagAuthType && <IdJagFormFields />}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -393,6 +393,50 @@ describe("MCPServerEdit (auth type switch)", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the ID-JAG arm on edit and nulls its shared fields when switching away", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
...interactiveOAuthServer,
|
||||
auth_type: "none",
|
||||
});
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{
|
||||
...interactiveOAuthServer,
|
||||
auth_type: "oauth2_id_jag",
|
||||
token_exchange_endpoint: "https://org.example.com/oauth2/v1/token",
|
||||
audience: "https://ras.example.com",
|
||||
}}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("https://your-org.okta.com/oauth2/v1/token")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByPlaceholderText("https://mcp-as.example.com/oauth2/token")).toBeInTheDocument();
|
||||
|
||||
await selectAntOption("Authentication", "None");
|
||||
|
||||
const saveButtons = screen.getAllByRole("button", { name: "Save Changes" });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButtons[0]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(networking.updateMCPServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [, payload] = vi.mocked(networking.updateMCPServer).mock.calls[0];
|
||||
expect(payload.auth_type).toBe("none");
|
||||
expect(payload.token_exchange_endpoint).toBeNull();
|
||||
expect(payload.audience).toBeNull();
|
||||
expect(payload.subject_token_type).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale oauth2 endpoint overrides when switching to token exchange", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
...interactiveOAuthServer,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection";
|
|||
import MCPToolConfiguration from "./mcp_tool_configuration";
|
||||
import StdioConfiguration from "./StdioConfiguration";
|
||||
import TokenExchangeFormFields from "./TokenExchangeFormFields";
|
||||
import IdJagFormFields from "./IdJagFormFields";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
|
|
@ -62,6 +63,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
|
|||
...AUTH_TYPES_REQUIRING_AUTH_VALUE,
|
||||
AUTH_TYPE.OAUTH2,
|
||||
AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE,
|
||||
AUTH_TYPE.OAUTH2_ID_JAG,
|
||||
AUTH_TYPE.AWS_SIGV4,
|
||||
AUTH_TYPE.TRUE_PASSTHROUGH,
|
||||
AUTH_TYPE.OAUTH_DELEGATE,
|
||||
|
|
@ -101,6 +103,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
|
||||
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
|
||||
const isTokenExchangeAuthType = authType === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE;
|
||||
const isIdJagAuthType = authType === AUTH_TYPE.OAUTH2_ID_JAG;
|
||||
const isAwsSigV4AuthType = authType === AUTH_TYPE.AWS_SIGV4;
|
||||
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
|
||||
const isM2MFlow = isOAuthAuthType && oauthFlowTypeValue === OAUTH_FLOW.M2M;
|
||||
|
|
@ -854,6 +857,9 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
|
||||
? { token_exchange_endpoint: null, audience: null, subject_token_type: null, token_exchange_profile: null }
|
||||
: {}),
|
||||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_ID_JAG && restValues.auth_type !== AUTH_TYPE.OAUTH2_ID_JAG
|
||||
? { token_exchange_endpoint: null, audience: null, subject_token_type: null }
|
||||
: {}),
|
||||
server_id: mcpServer.server_id,
|
||||
mcp_info: {
|
||||
...(mcpServer.mcp_info ?? {}),
|
||||
|
|
@ -1106,7 +1112,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
{!isStdioTransport && (
|
||||
<>
|
||||
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
|
||||
<Select>
|
||||
<Select virtual={false}>
|
||||
<Select.Option value="none">None</Select.Option>
|
||||
<Select.Option value="api_key">API Key</Select.Option>
|
||||
<Select.Option value="bearer_token">Bearer Token</Select.Option>
|
||||
|
|
@ -1114,6 +1120,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
<Select.Option value="basic">Basic Auth</Select.Option>
|
||||
<Select.Option value="oauth2">OAuth</Select.Option>
|
||||
<Select.Option value="oauth2_token_exchange">OAuth Token Exchange (OBO)</Select.Option>
|
||||
<Select.Option value="oauth2_id_jag">Enterprise-Managed Authorization (ID-JAG)</Select.Option>
|
||||
<Select.Option value="aws_sigv4">AWS SigV4 (Bedrock AgentCore MCPs)</Select.Option>
|
||||
<Select.Option value="true_passthrough">True Passthrough (no LiteLLM auth)</Select.Option>
|
||||
<Select.Option value="oauth_delegate">
|
||||
|
|
@ -1443,6 +1450,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
)}
|
||||
|
||||
{!isStdioTransport && isTokenExchangeAuthType && <TokenExchangeFormFields isEditing />}
|
||||
{!isStdioTransport && isIdJagAuthType && <IdJagFormFields isEditing />}
|
||||
|
||||
{!isStdioTransport && isAwsSigV4AuthType && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import React from "react";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import MCPAppsPanel from "./MCPAppsPanel";
|
||||
import * as networking from "../networking";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
fetchMCPServers: vi.fn(),
|
||||
getMCPOAuthUserCredentialStatus: vi.fn(),
|
||||
deleteMCPOAuthUserCredential: vi.fn(),
|
||||
listMCPTools: vi.fn(),
|
||||
getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"),
|
||||
}));
|
||||
|
||||
const idJagServer = {
|
||||
server_id: "srv-ema",
|
||||
server_name: "ema_upstream",
|
||||
alias: "ema_upstream",
|
||||
url: "https://up.example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "oauth2_id_jag",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "u",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "u",
|
||||
};
|
||||
|
||||
const plainServer = {
|
||||
...idJagServer,
|
||||
server_id: "srv-plain",
|
||||
server_name: "plain_upstream",
|
||||
alias: "plain_upstream",
|
||||
auth_type: "none",
|
||||
};
|
||||
|
||||
const renderPanel = () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MCPAppsPanel accessToken="sk-test" selectedServers={[]} onChange={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("MCPAppsPanel enterprise-managed authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(networking.fetchMCPServers).mockResolvedValue([idJagServer, plainServer] as never);
|
||||
vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: [] } as never);
|
||||
});
|
||||
|
||||
it("renders an id_jag server as already connected and never fetches its credential status", async () => {
|
||||
const { container } = renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ema_upstream")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tile = screen.getByText("ema_upstream").closest("div[class*='cursor-pointer']") ?? container;
|
||||
expect(tile.querySelector("svg.text-emerald-600")).toBeTruthy();
|
||||
expect(networking.getMCPOAuthUserCredentialStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the connected badge and no connect affordance in the id_jag detail pane", async () => {
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ema_upstream")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText("ema_upstream"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Connected via your organization sign-in")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /^Connect$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Disconnect$/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the plain Connect toggle for a non id_jag server", async () => {
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("plain_upstream")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByText("plain_upstream"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /^Connect$/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Connected via your organization sign-in")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
getMCPOAuthUserCredentialStatus,
|
||||
listMCPTools,
|
||||
} from "../networking";
|
||||
import { AUTH_TYPE, MCPServer, MCPTool, handleTransport } from "../mcp_tools/types";
|
||||
import { AUTH_TYPE, MCPServer, MCPTool, handleTransport, isAutoConnectedAuthType } from "../mcp_tools/types";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { useUserMcpOAuthFlow } from "@/hooks/useUserMcpOAuthFlow";
|
||||
|
||||
|
|
@ -294,48 +294,55 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
|||
<h2 className="m-0 mb-1 text-[22px] font-bold text-foreground">{name}</h2>
|
||||
<p className="m-0 text-sm text-muted-foreground">{detailServer.description ?? "MCP server"}</p>
|
||||
</div>
|
||||
{detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
|
||||
oauthConnected.has(detailServer.server_id) ? (
|
||||
{isAutoConnectedAuthType(detailServer.auth_type) && (
|
||||
<span className="inline-flex items-center gap-1.5 h-[38px] px-3 rounded-md bg-emerald-50 text-emerald-700 text-sm font-semibold">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
Connected via your organization sign-in
|
||||
</span>
|
||||
)}
|
||||
{!isAutoConnectedAuthType(detailServer.auth_type) &&
|
||||
(detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
|
||||
oauthConnected.has(detailServer.server_id) ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
|
||||
} catch (_) {
|
||||
// Ignore
|
||||
}
|
||||
setOauthConnected((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(detailServer.server_id);
|
||||
return n;
|
||||
});
|
||||
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
|
||||
}}
|
||||
className="font-semibold h-[38px] min-w-[110px]"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<OAuth2ConnectButton
|
||||
server={detailServer}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
}}
|
||||
variant="button"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteMCPOAuthUserCredential(accessToken, detailServer.server_id);
|
||||
} catch (_) {
|
||||
// Ignore
|
||||
}
|
||||
setOauthConnected((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(detailServer.server_id);
|
||||
return n;
|
||||
});
|
||||
onChangeRef.current(selectedServersRef.current.filter((s) => s !== name));
|
||||
}}
|
||||
variant={isConnected ? "outline" : "default"}
|
||||
disabled={isTogglingOn}
|
||||
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
|
||||
className="font-semibold h-[38px] min-w-[110px]"
|
||||
>
|
||||
Disconnect
|
||||
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
<OAuth2ConnectButton
|
||||
server={detailServer}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
}}
|
||||
variant="button"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Button
|
||||
variant={isConnected ? "outline" : "default"}
|
||||
disabled={isTogglingOn}
|
||||
onClick={() => handleToggle(name, !isConnected, detailServer.server_id)}
|
||||
className="font-semibold h-[38px] min-w-[110px]"
|
||||
>
|
||||
{isTogglingOn && <Loader2 className="h-4 w-4 animate-spin mr-1.5" />}
|
||||
{isConnected ? "Disconnect" : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h3 className="m-0 mb-3 text-[15px] font-semibold text-foreground">Information</h3>
|
||||
|
|
@ -513,22 +520,32 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
|||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{server.auth_type === AUTH_TYPE.OAUTH2 ? (
|
||||
oauthConnected.has(server.server_id) ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />
|
||||
) : (
|
||||
<OAuth2ConnectButton
|
||||
server={server}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
}}
|
||||
variant="badge"
|
||||
/>
|
||||
)
|
||||
) : isConnected ? (
|
||||
<span className="w-[7px] h-[7px] rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0" />
|
||||
) : null}
|
||||
{(() => {
|
||||
if (isAutoConnectedAuthType(server.auth_type)) {
|
||||
return <CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />;
|
||||
}
|
||||
if (server.auth_type === AUTH_TYPE.OAUTH2) {
|
||||
if (oauthConnected.has(server.server_id)) {
|
||||
return <CheckCircle className="h-3.5 w-3.5 text-emerald-600 shrink-0" />;
|
||||
}
|
||||
return (
|
||||
<OAuth2ConnectButton
|
||||
server={server}
|
||||
accessToken={accessToken}
|
||||
onConnect={(id) => {
|
||||
setOauthConnected((prev) => new Set(prev).add(id));
|
||||
}}
|
||||
variant="badge"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isConnected) {
|
||||
return (
|
||||
<span className="w-[7px] h-[7px] rounded-full bg-emerald-600 dark:bg-emerald-400 shrink-0" />
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -231,3 +231,19 @@ describe("credentialAuthClass", () => {
|
|||
expect(credentialAuthClass(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("id_jag auth type", () => {
|
||||
it("classifies oauth2_id_jag as the id_jag oauth mode", async () => {
|
||||
const { getMcpOAuthMode, AUTH_TYPE } = await import("./types");
|
||||
expect(getMcpOAuthMode({ auth_type: AUTH_TYPE.OAUTH2_ID_JAG })).toBe("id_jag");
|
||||
});
|
||||
|
||||
it("auto-connects only oauth2_id_jag servers in the Apps grid", async () => {
|
||||
const { isAutoConnectedAuthType, AUTH_TYPE } = await import("./types");
|
||||
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2_ID_JAG)).toBe(true);
|
||||
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2)).toBe(false);
|
||||
expect(isAutoConnectedAuthType(AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE)).toBe(false);
|
||||
expect(isAutoConnectedAuthType(null)).toBe(false);
|
||||
expect(isAutoConnectedAuthType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const AUTH_TYPE = {
|
|||
BASIC: "basic",
|
||||
OAUTH2: "oauth2",
|
||||
OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange",
|
||||
OAUTH2_ID_JAG: "oauth2_id_jag",
|
||||
AWS_SIGV4: "aws_sigv4",
|
||||
TRUE_PASSTHROUGH: "true_passthrough",
|
||||
OAUTH_DELEGATE: "oauth_delegate",
|
||||
|
|
@ -161,7 +162,14 @@ export const MCP_OAUTH2_FLOW_M2M = "client_credentials";
|
|||
|
||||
export const MCP_OAUTH2_FLOW_INTERACTIVE = "authorization_code";
|
||||
|
||||
export type McpOAuthMode = "m2m" | "passthrough" | "authorization_code" | "token_exchange";
|
||||
export type McpOAuthMode = "m2m" | "passthrough" | "authorization_code" | "token_exchange" | "id_jag";
|
||||
|
||||
/** Enterprise-managed authorization: the user's one SSO login is the only interaction, tokens
|
||||
* mint via back-channel exchange, so the Apps grid renders these servers as already connected
|
||||
* with no connect affordance. */
|
||||
export function isAutoConnectedAuthType(authType?: string | null): boolean {
|
||||
return authType === AUTH_TYPE.OAUTH2_ID_JAG;
|
||||
}
|
||||
|
||||
// Classify an OAuth MCP server into the mode that decides how the tool list is
|
||||
// authenticated. token_exchange (RFC 8693 / OBO) is its own auth_type
|
||||
|
|
@ -180,6 +188,7 @@ export function getMcpOAuthMode(s: {
|
|||
delegate_auth_to_upstream?: boolean | null;
|
||||
}): McpOAuthMode | null {
|
||||
if (s.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE) return "token_exchange";
|
||||
if (s.auth_type === AUTH_TYPE.OAUTH2_ID_JAG) return "id_jag";
|
||||
if (s.auth_type !== AUTH_TYPE.OAUTH2) return null;
|
||||
if (s.oauth2_flow === MCP_OAUTH2_FLOW_M2M) return "m2m";
|
||||
return s.delegate_auth_to_upstream ? "passthrough" : "authorization_code";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue