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:
Yassin Kortam 2026-07-20 14:49:36 -07:00
parent 214945a223
commit 7a2b167805
7 changed files with 230 additions and 4 deletions

View file

@ -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;

View file

@ -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();

View file

@ -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 />}
</>
),
},

View file

@ -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 });

View file

@ -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 && (
<>

View file

@ -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