mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-17 23:51:30 +00:00
feat(ui): add ID-JAG (Okta Cross App Access) auth type to MCP server form
Admins could configure an oauth2_id_jag MCP server only through config.yaml or
the REST API; the dashboard's Add/Edit MCP Server form had no ID-JAG option, so
the leg-2 resource token endpoint and resource indicator were unreachable from
the UI. Add an ID-JAG auth type that renders the two-leg fields (IdP token
endpoint for leg 1, resource token endpoint for leg 2, client id/secret,
audience, resource indicator, scopes), sending the credential-blob fields nested
under credentials so the existing backend persists them unchanged.
The auth-type Select is switched to virtual={false} so all options render
deterministically; the antd virtual list otherwise drops the last option once
the list grows, which is both an a11y gap and what broke the option-selection
tests.
This commit is contained in:
parent
214945a223
commit
7a2b167805
7 changed files with 230 additions and 4 deletions
|
|
@ -0,0 +1,105 @@
|
|||
import React 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)" : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="IdP Token Endpoint (leg 1)"
|
||||
tooltip="The identity provider's org token endpoint where the gateway exchanges the caller's identity token for an ID-JAG assertion (RFC 8693 token-exchange). For Okta this is https://<your-okta-domain>/oauth2/v1/token."
|
||||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
rules={[{ required: !isEditing, message: "The IdP token endpoint is required for ID-JAG" }]}
|
||||
>
|
||||
<Input placeholder="https://your-okta-domain.okta.com/oauth2/v1/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Token Endpoint (leg 2)"
|
||||
tooltip="The resource app's own token endpoint where the gateway presents the ID-JAG assertion (RFC 7523 jwt-bearer) to obtain a scoped access token for the upstream MCP server. This is the resource server's authorization server, not the IdP."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
rules={[{ required: !isEditing, message: "The resource token endpoint is required for ID-JAG" }]}
|
||||
>
|
||||
<Input placeholder="https://mcp.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Client ID"
|
||||
tooltip="OAuth2 client ID the gateway (the requesting app registered with your IdP) uses to authenticate on both legs of the exchange."
|
||||
/>
|
||||
}
|
||||
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 Secret"
|
||||
tooltip="OAuth2 client secret used to authenticate the gateway to the 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>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Audience (optional)"
|
||||
tooltip="Target audience sent on leg 1 (RFC 8693 audience). Identifies the resource app the ID-JAG assertion is minted for."
|
||||
/>
|
||||
}
|
||||
name="audience"
|
||||
>
|
||||
<Input placeholder="api://your-mcp-resource" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
label="Resource Indicator (optional)"
|
||||
tooltip="RFC 8707 resource indicator sent on leg 1, identifying the protected resource the token is intended for."
|
||||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource"]}
|
||||
>
|
||||
<Input placeholder="https://mcp.example.com" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={<FieldLabel label="Scopes (optional)" tooltip="Optional scopes requested on leg 1 of the exchange." />}
|
||||
name={["credentials", "scopes"]}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdJagFormFields;
|
||||
|
|
@ -1058,6 +1058,73 @@ describe("CreateMCPServer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("routes ID-JAG (Okta Cross App Access) config to the backend payload", async () => {
|
||||
await selectHttpTransport();
|
||||
|
||||
fireEvent.change(getServerNameInput(), { target: { value: "IdJag_Server" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-mcp-server.com"), {
|
||||
target: { value: "https://upstream.example.com/mcp" },
|
||||
});
|
||||
|
||||
await selectAntOption("Authentication", "ID-JAG (Okta Cross App Access)");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("https://your-okta-domain.okta.com/oauth2/v1/token")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://your-okta-domain.okta.com/oauth2/v1/token"), {
|
||||
target: { value: "https://acme.okta.com/oauth2/v1/token" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("api://your-mcp-resource"), {
|
||||
target: { value: "api://mcp-resource" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("https://mcp.example.com/oauth2/token"), {
|
||||
target: { value: "https://mcp.example.com/oauth2/token" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("https://mcp.example.com"), {
|
||||
target: { value: "https://mcp.example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client ID"), {
|
||||
target: { value: "idjag-client-id" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), {
|
||||
target: { value: "idjag-client-secret" },
|
||||
});
|
||||
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-idjag",
|
||||
server_name: "IdJag_Server",
|
||||
alias: "IdJag_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://acme.okta.com/oauth2/v1/token");
|
||||
expect(payload.audience).toBe("api://mcp-resource");
|
||||
expect(payload.credentials).toMatchObject({
|
||||
client_id: "idjag-client-id",
|
||||
client_secret: "idjag-client-secret",
|
||||
id_jag_resource_token_endpoint: "https://mcp.example.com/oauth2/token",
|
||||
id_jag_resource: "https://mcp.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
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,7 @@ 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">ID-JAG (Okta Cross App Access)</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 +1148,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
)}
|
||||
|
||||
{isTokenExchangeAuthType && <TokenExchangeFormFields />}
|
||||
{isIdJagAuthType && <IdJagFormFields />}
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -432,6 +432,44 @@ describe("MCPServerEdit (auth type switch)", () => {
|
|||
expect(payload.registration_url).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the shared leg-1 endpoint and audience when switching token exchange to ID-JAG", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue({
|
||||
...interactiveOAuthServer,
|
||||
auth_type: "oauth2_id_jag",
|
||||
});
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={{
|
||||
...interactiveOAuthServer,
|
||||
auth_type: "oauth2_token_exchange",
|
||||
token_exchange_endpoint: "https://idp.example.com/oauth2/token",
|
||||
audience: "api://existing-resource",
|
||||
}}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectAntOption("Authentication", "ID-JAG (Okta Cross App Access)");
|
||||
|
||||
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("oauth2_id_jag");
|
||||
expect(payload.token_exchange_endpoint).toBe("https://idp.example.com/oauth2/token");
|
||||
expect(payload.audience).toBe("api://existing-resource");
|
||||
});
|
||||
|
||||
it("keeps oauth2 endpoint overrides when the auth type is unchanged", 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,10 +63,12 @@ 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,
|
||||
];
|
||||
const AUTH_TYPES_SHARING_TOKEN_EXCHANGE_COLUMNS = [AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE, AUTH_TYPE.OAUTH2_ID_JAG];
|
||||
export const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
|
||||
|
||||
const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
||||
|
|
@ -101,6 +104,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;
|
||||
|
|
@ -850,9 +854,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2 && restValues.auth_type !== AUTH_TYPE.OAUTH2
|
||||
? { issuer: null, authorization_url: null, token_url: null, registration_url: null }
|
||||
: {}),
|
||||
...(AUTH_TYPES_SHARING_TOKEN_EXCHANGE_COLUMNS.includes(mcpServer.auth_type ?? "") &&
|
||||
!AUTH_TYPES_SHARING_TOKEN_EXCHANGE_COLUMNS.includes(restValues.auth_type ?? "")
|
||||
? { token_exchange_endpoint: null, audience: null }
|
||||
: {}),
|
||||
...(mcpServer.auth_type === AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE &&
|
||||
restValues.auth_type !== AUTH_TYPE.OAUTH2_TOKEN_EXCHANGE
|
||||
? { token_exchange_endpoint: null, audience: null, subject_token_type: null, token_exchange_profile: null }
|
||||
? { subject_token_type: null, token_exchange_profile: null }
|
||||
: {}),
|
||||
server_id: mcpServer.server_id,
|
||||
mcp_info: {
|
||||
|
|
@ -1106,7 +1114,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 +1122,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">ID-JAG (Okta Cross App Access)</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 +1452,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
)}
|
||||
|
||||
{!isStdioTransport && isTokenExchangeAuthType && <TokenExchangeFormFields isEditing />}
|
||||
{!isStdioTransport && isIdJagAuthType && <IdJagFormFields isEditing />}
|
||||
|
||||
{!isStdioTransport && isAwsSigV4AuthType && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue