mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
fix(ui): make the selected ID-JAG client-auth method authoritative and keep chat selection for auto-connected servers
This commit is contained in:
parent
19e9a31a52
commit
e571ac7c3e
7 changed files with 137 additions and 7 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { Form, Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
|
|
@ -19,7 +19,10 @@ const FieldLabel: React.FC<{ label: string; tooltip: string }> = ({ label, toolt
|
|||
|
||||
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");
|
||||
const form = Form.useFormInstance();
|
||||
const clientAuthMethod =
|
||||
(Form.useWatch("id_jag_client_auth_method", form) as "client_secret" | "private_key_jwt" | undefined) ??
|
||||
"client_secret";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -31,6 +34,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name="token_exchange_endpoint"
|
||||
preserve={false}
|
||||
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} />
|
||||
|
|
@ -43,6 +47,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "id_jag_resource_token_endpoint"]}
|
||||
preserve={false}
|
||||
>
|
||||
<Input placeholder="https://mcp-as.example.com/oauth2/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
|
|
@ -54,6 +59,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_id"]}
|
||||
preserve={false}
|
||||
rules={[{ required: !isEditing, message: "Client ID is required for ID-JAG" }]}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client ID${placeholderSuffix}`} className={fieldClassName} />
|
||||
|
|
@ -62,15 +68,16 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
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)."
|
||||
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). The selected method is authoritative; the other method's stored fields are cleared on save."
|
||||
/>
|
||||
}
|
||||
name="id_jag_client_auth_method"
|
||||
initialValue="client_secret"
|
||||
preserve={false}
|
||||
>
|
||||
<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> },
|
||||
|
|
@ -86,6 +93,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_secret"]}
|
||||
preserve={false}
|
||||
rules={[{ required: !isEditing, message: "Client Secret is required for ID-JAG" }]}
|
||||
>
|
||||
<Input.Password placeholder={`Enter OAuth client secret${placeholderSuffix}`} className={fieldClassName} />
|
||||
|
|
@ -101,6 +109,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key"]}
|
||||
preserve={false}
|
||||
rules={[{ required: !isEditing, message: "A private key is required for private_key_jwt" }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
|
|
@ -117,6 +126,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_private_key_id"]}
|
||||
preserve={false}
|
||||
>
|
||||
<Input placeholder="key-2026-01" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
|
|
@ -128,6 +138,7 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name={["credentials", "client_assertion_signing_alg"]}
|
||||
preserve={false}
|
||||
>
|
||||
<Input placeholder="RS256" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
|
|
@ -141,12 +152,14 @@ const IdJagFormFields: React.FC<IdJagFormFieldsProps> = ({ isEditing = false })
|
|||
/>
|
||||
}
|
||||
name="audience"
|
||||
preserve={false}
|
||||
>
|
||||
<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"]}
|
||||
preserve={false}
|
||||
>
|
||||
<Select mode="tags" tokenSeparators={[","]} placeholder="Add scopes" className="rounded-lg" size="large" />
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -1177,7 +1177,67 @@ describe("CreateMCPServer", () => {
|
|||
client_private_key: expect.stringContaining("BEGIN PRIVATE KEY"),
|
||||
client_private_key_id: "kid-1",
|
||||
});
|
||||
expect(payload.credentials.client_secret).toBeUndefined();
|
||||
expect(payload.credentials.client_secret).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls the private-key fields when client secret is the selected ID-JAG method", async () => {
|
||||
await selectHttpTransport();
|
||||
fireEvent.change(getServerNameInput(), { target: { value: "EMA_Back_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();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("-----BEGIN PRIVATE KEY-----"), {
|
||||
target: { value: "stale-key-material" },
|
||||
});
|
||||
await selectAntOption("Client Authentication", "Client Secret");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Enter OAuth client secret")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter OAuth client secret"), {
|
||||
target: { value: "final-secret" },
|
||||
});
|
||||
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-ema-back",
|
||||
server_name: "EMA_Back_Server",
|
||||
alias: "EMA_Back_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.credentials.client_secret).toBe("final-secret");
|
||||
expect(payload.credentials.client_private_key).toBeNull();
|
||||
expect(payload.credentials.client_private_key_id).toBeNull();
|
||||
expect(payload.credentials.client_assertion_signing_alg).toBeNull();
|
||||
expect(payload.id_jag_client_auth_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("makes scope required when the Entra OBO profile is selected", async () => {
|
||||
|
|
|
|||
|
|
@ -445,6 +445,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
oauth_passthrough: oauthPassthroughRaw,
|
||||
dcr_bridge: dcrBridgeRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
id_jag_client_auth_method: idJagClientAuthMethodRaw,
|
||||
...restValues
|
||||
} = values;
|
||||
|
||||
|
|
@ -579,6 +580,16 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
payload.credentials = submitCredentials;
|
||||
}
|
||||
|
||||
// The selected ID-JAG client-auth method is authoritative: null the other method's
|
||||
// fields so the backend's credentials merge cannot keep a stale method alive.
|
||||
if (restValues.auth_type === AUTH_TYPE.OAUTH2_ID_JAG) {
|
||||
const idJagMethodNulls =
|
||||
(idJagClientAuthMethodRaw ?? "client_secret") === "private_key_jwt"
|
||||
? { client_secret: null }
|
||||
: { client_private_key: null, client_private_key_id: null, client_assertion_signing_alg: null };
|
||||
payload.credentials = { ...(payload.credentials ?? {}), ...idJagMethodNulls };
|
||||
}
|
||||
|
||||
// An interactive (oauth2) create persists its DCR-minted client from the ref (kept out of the
|
||||
// form store); reuse a re-authorize's registered client instead of re-registering.
|
||||
if (restValues.auth_type === AUTH_TYPE.OAUTH2 && dcrClientRef.current) {
|
||||
|
|
|
|||
|
|
@ -435,6 +435,12 @@ describe("MCPServerEdit (auth type switch)", () => {
|
|||
expect(payload.token_exchange_endpoint).toBeNull();
|
||||
expect(payload.audience).toBeNull();
|
||||
expect(payload.subject_token_type).toBeNull();
|
||||
expect(payload.credentials).toMatchObject({
|
||||
id_jag_resource_token_endpoint: null,
|
||||
client_private_key: null,
|
||||
client_private_key_id: null,
|
||||
client_assertion_signing_alg: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears stale oauth2 endpoint overrides when switching to token exchange", async () => {
|
||||
|
|
|
|||
|
|
@ -697,6 +697,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
oauth_passthrough: oauthPassthroughRaw,
|
||||
dcr_bridge: dcrBridgeRaw,
|
||||
token_validation_json: rawTokenValidationJson,
|
||||
id_jag_client_auth_method: idJagClientAuthMethodRaw,
|
||||
...restValues
|
||||
} = values;
|
||||
|
||||
|
|
@ -949,6 +950,28 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
payload.credentials = { client_id: null, client_secret: null };
|
||||
}
|
||||
|
||||
// The selected ID-JAG client-auth method is authoritative: explicit-null the other
|
||||
// method's stored fields so the backend's credentials merge cannot keep it alive.
|
||||
if (restValues.auth_type === AUTH_TYPE.OAUTH2_ID_JAG) {
|
||||
const idJagMethodNulls =
|
||||
(idJagClientAuthMethodRaw ?? "client_secret") === "private_key_jwt"
|
||||
? { client_secret: null }
|
||||
: { client_private_key: null, client_private_key_id: null, client_assertion_signing_alg: null };
|
||||
payload.credentials = { ...(payload.credentials ?? {}), ...idJagMethodNulls };
|
||||
}
|
||||
|
||||
// Leaving ID-JAG for another credentials-bearing auth type: explicit-null the ID-JAG
|
||||
// blob fields, or a same-credential-class merge keeps the stale endpoint and key.
|
||||
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2_ID_JAG && restValues.auth_type !== AUTH_TYPE.OAUTH2_ID_JAG) {
|
||||
payload.credentials = {
|
||||
...(payload.credentials ?? {}),
|
||||
id_jag_resource_token_endpoint: null,
|
||||
client_private_key: null,
|
||||
client_private_key_id: null,
|
||||
client_assertion_signing_alg: null,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await updateMCPServer(accessToken, payload);
|
||||
|
||||
// Persist the token staged via "Authorize & Fetch" (mirrors the create flow's
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe("MCPAppsPanel enterprise-managed authorization", () => {
|
|||
expect(networking.getMCPOAuthUserCredentialStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the connected badge and no connect affordance in the id_jag detail pane", async () => {
|
||||
it("shows the connected badge with chat selection but no credential connect in the id_jag detail pane", async () => {
|
||||
renderPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -75,6 +75,12 @@ describe("MCPAppsPanel enterprise-managed authorization", () => {
|
|||
});
|
||||
expect(screen.queryByRole("button", { name: /^Connect$/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /^Disconnect$/ })).not.toBeInTheDocument();
|
||||
|
||||
const addButton = screen.getByRole("button", { name: /Add to chat/ });
|
||||
fireEvent.click(addButton);
|
||||
await waitFor(() => {
|
||||
expect(networking.listMCPTools).toHaveBeenCalledWith("sk-test", "srv-ema");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the plain Connect toggle for a non id_jag server", async () => {
|
||||
|
|
|
|||
|
|
@ -300,6 +300,17 @@ const MCPAppsPanel: React.FC<Props> = ({ accessToken, selectedServers, onChange
|
|||
Connected via your organization sign-in
|
||||
</span>
|
||||
)}
|
||||
{isAutoConnectedAuthType(detailServer.auth_type) && (
|
||||
<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 ? "Remove from chat" : "Add to chat"}
|
||||
</Button>
|
||||
)}
|
||||
{!isAutoConnectedAuthType(detailServer.auth_type) &&
|
||||
(detailServer.auth_type === AUTH_TYPE.OAUTH2 ? (
|
||||
oauthConnected.has(detailServer.server_id) ? (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue