mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
feat(ui): add token endpoint auth method selector to MCP OAuth forms (#31739)
PR #31635 added a per-server token_endpoint_auth_method (client_secret_basic or client_secret_post) for upstream OAuth token endpoints, but it could only be set by editing the stored credentials JSON. This surfaces it in the dashboard as an optional selector directly under the Token URL field, in both the create form (OAuthFormFields, M2M and interactive flows) and the edit form. The field binds to credentials.token_endpoint_auth_method, which the backend already reads; the value is sent only when chosen, so leaving it blank keeps the existing setting and preserves the client_secret_post default.
This commit is contained in:
parent
d0e785140c
commit
b59ad212f4
7 changed files with 253 additions and 39 deletions
|
|
@ -91,6 +91,47 @@ describe("OAuthFormFields", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("token endpoint auth method selector", () => {
|
||||
it("renders directly below the Token URL field in interactive mode", () => {
|
||||
render(
|
||||
<WithForm>
|
||||
<OAuthFormFields isM2M={false} />
|
||||
</WithForm>,
|
||||
);
|
||||
const tokenUrlLabel = screen.getByText("Token URL (optional)");
|
||||
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
|
||||
expect(tokenUrlLabel.compareDocumentPosition(authMethodLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders directly below the Token URL field in M2M mode", () => {
|
||||
render(
|
||||
<WithForm>
|
||||
<OAuthFormFields isM2M={true} />
|
||||
</WithForm>,
|
||||
);
|
||||
const tokenUrlLabel = screen.getByText("Token URL");
|
||||
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
|
||||
expect(tokenUrlLabel.compareDocumentPosition(authMethodLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers client_secret_basic and client_secret_post options", async () => {
|
||||
render(
|
||||
<WithForm>
|
||||
<OAuthFormFields isM2M={false} />
|
||||
</WithForm>,
|
||||
);
|
||||
const authMethodLabel = screen.getByText("Token Endpoint Auth Method (optional)");
|
||||
const selector = authMethodLabel.closest(".ant-form-item")!.querySelector(".ant-select-selector")!;
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(selector);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Client Secret Basic")).toBeInTheDocument();
|
||||
expect(screen.getByText("Client Secret Post")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── token_validation_json inline JSON validator ──────────────────────────────
|
||||
|
||||
describe("token_validation_json validation", () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Form, Input, InputNumber, Select, Tooltip } from "antd";
|
|||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Button, TextInput } from "@tremor/react";
|
||||
import { OAUTH_FLOW } from "./types";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
|
||||
interface OAuthFlowStatus {
|
||||
startOAuthFlow: () => void;
|
||||
|
|
@ -101,6 +102,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
>
|
||||
<TextInput placeholder="https://auth.example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
|
|
@ -182,6 +184,7 @@ const OAuthFormFields: React.FC<OAuthFormFieldsProps> = ({
|
|||
>
|
||||
<TextInput placeholder="https://example.com/oauth/token" className={fieldClassName} />
|
||||
</Form.Item>
|
||||
<TokenEndpointAuthMethodField isEditing={isEditing} />
|
||||
<Form.Item
|
||||
label={
|
||||
<FieldLabel
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
import { Form, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
const TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS = [
|
||||
{ value: "client_secret_basic", label: "Client Secret Basic" },
|
||||
{ value: "client_secret_post", label: "Client Secret Post" },
|
||||
];
|
||||
|
||||
interface TokenEndpointAuthMethodFieldProps {
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
const TokenEndpointAuthMethodField: React.FC<TokenEndpointAuthMethodFieldProps> = ({ isEditing = false }) => (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
Token Endpoint Auth Method (optional)
|
||||
<Tooltip title="How the proxy authenticates to the upstream OAuth token endpoint. Client Secret Basic sends the client credentials in an HTTP Basic Authorization header; leave blank to use the default, Client Secret Post, which sends them in the request body.">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name={["credentials", "token_endpoint_auth_method"]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={
|
||||
isEditing ? "Leave blank to keep existing (default Client Secret Post)" : "Default (Client Secret Post)"
|
||||
}
|
||||
className="rounded-lg"
|
||||
size="large"
|
||||
options={TOKEN_ENDPOINT_AUTH_METHOD_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
export default TokenEndpointAuthMethodField;
|
||||
|
|
@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import * as networking from "../networking";
|
||||
import { setToken } from "@/utils/mcpTokenStore";
|
||||
import CreateMCPServer from "./create_mcp_server";
|
||||
import { selectAntOption } from "./testUtils";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
createMCPServer: vi.fn(),
|
||||
|
|
@ -88,45 +89,6 @@ const defaultProps = {
|
|||
/** Helper: get the server_name input by its Ant Form id */
|
||||
const getServerNameInput = () => document.getElementById("server_name") as HTMLInputElement;
|
||||
|
||||
/** Helper: select a dropdown option by opening a select near a label and clicking an option */
|
||||
async function selectAntOption(labelText: string, optionText: string) {
|
||||
const label = screen.getByText(labelText);
|
||||
// First try to find a .ant-form-item ancestor (standard form fields)
|
||||
let select: Element | null = null;
|
||||
const formItem = label.closest(".ant-form-item");
|
||||
if (formItem) {
|
||||
select = formItem.querySelector(".ant-select");
|
||||
}
|
||||
// If not found, try .ant-collapse-content ancestor (auth type is inside a Collapse panel)
|
||||
if (!select) {
|
||||
const collapseContent = label.closest(".ant-collapse-item");
|
||||
if (collapseContent) {
|
||||
select = collapseContent.querySelector(".ant-select");
|
||||
}
|
||||
}
|
||||
// Fallback: look for a sibling or nearby select
|
||||
if (!select) {
|
||||
const parent = label.closest("div");
|
||||
select = parent?.querySelector(".ant-select") ?? null;
|
||||
}
|
||||
act(() => {
|
||||
fireEvent.mouseDown(select!.querySelector(".ant-select-selector")!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const options = document.querySelectorAll(".ant-select-item-option");
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const option = Array.from(document.querySelectorAll(".ant-select-item-option")).find((el) =>
|
||||
el.textContent?.includes(optionText),
|
||||
);
|
||||
expect(option).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.click(option!);
|
||||
});
|
||||
}
|
||||
|
||||
describe("CreateMCPServer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -534,6 +496,84 @@ describe("CreateMCPServer", () => {
|
|||
expect(payload.token_validation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes credentials.token_endpoint_auth_method in payload when client_secret_basic is selected", async () => {
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-oauth",
|
||||
server_name: "OAuth_Server",
|
||||
alias: "OAuth_Server",
|
||||
url: "https://example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "oauth2",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
});
|
||||
|
||||
await setupOAuthInteractive();
|
||||
|
||||
const nameInput = document.getElementById("server_name") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
|
||||
});
|
||||
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
|
||||
});
|
||||
|
||||
await selectAntOption("Token Endpoint Auth Method (optional)", "Client Secret Basic");
|
||||
|
||||
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.credentials?.token_endpoint_auth_method).toBe("client_secret_basic");
|
||||
});
|
||||
|
||||
it("omits token_endpoint_auth_method from credentials when left blank", async () => {
|
||||
vi.mocked(networking.createMCPServer).mockResolvedValue({
|
||||
server_id: "new-server-oauth",
|
||||
server_name: "OAuth_Server",
|
||||
alias: "OAuth_Server",
|
||||
url: "https://example.com/mcp",
|
||||
transport: "http",
|
||||
auth_type: "oauth2",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
created_by: "user-1",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
updated_by: "user-1",
|
||||
});
|
||||
|
||||
await setupOAuthInteractive();
|
||||
|
||||
const nameInput = document.getElementById("server_name") as HTMLInputElement;
|
||||
await act(async () => {
|
||||
fireEvent.change(nameInput, { target: { value: "OAuth_Server" } });
|
||||
});
|
||||
const urlInput = screen.getByPlaceholderText("https://your-mcp-server.com");
|
||||
await act(async () => {
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/mcp" } });
|
||||
});
|
||||
|
||||
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.credentials?.token_endpoint_auth_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists access + refresh token to the DB on submit for OBO mode", async () => {
|
||||
// "Authorize & Fetch" produced a token before submit.
|
||||
oauthHook.tokenResponse = {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"
|
|||
import MCPServerEdit from "./mcp_server_edit";
|
||||
import * as networking from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { selectAntOption } from "./testUtils";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
updateMCPServer: vi.fn(),
|
||||
|
|
@ -506,6 +507,68 @@ describe("MCPServerEdit (interactive OAuth)", () => {
|
|||
expect(payload.token_validation).toEqual({ organization: "my-org" });
|
||||
});
|
||||
|
||||
it("includes credentials.token_endpoint_auth_method in update payload when client_secret_basic is selected", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={interactiveOAuthServer}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Token Endpoint Auth Method (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectAntOption("Token Endpoint Auth Method (optional)", "Client Secret Basic");
|
||||
|
||||
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.credentials?.token_endpoint_auth_method).toBe("client_secret_basic");
|
||||
});
|
||||
|
||||
it("omits token_endpoint_auth_method from the update payload when the selector is left blank", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);
|
||||
|
||||
render(
|
||||
<MCPServerEdit
|
||||
mcpServer={interactiveOAuthServer}
|
||||
accessToken="access-token"
|
||||
onCancel={vi.fn()}
|
||||
onSuccess={vi.fn()}
|
||||
availableAccessGroups={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Token Endpoint Auth Method (optional)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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.credentials?.token_endpoint_auth_method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not include token_validation in payload when field is empty and server had none", async () => {
|
||||
vi.mocked(networking.updateMCPServer).mockResolvedValue(interactiveOAuthServer);
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration";
|
|||
import StdioConfiguration from "./StdioConfiguration";
|
||||
import MCPLogoSelector from "./MCPLogoSelector";
|
||||
import EnvVarsSection from "./EnvVarsSection";
|
||||
import TokenEndpointAuthMethodField from "./TokenEndpointAuthMethodField";
|
||||
import { validateMCPServerUrl, validateMCPServerName, normalizeEnvVars } from "./utils";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
|
||||
|
|
@ -996,6 +997,7 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
|
|||
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</Form.Item>
|
||||
<TokenEndpointAuthMethodField isEditing />
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
|
|
|
|||
27
ui/litellm-dashboard/src/components/mcp_tools/testUtils.ts
Normal file
27
ui/litellm-dashboard/src/components/mcp_tools/testUtils.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { expect } from "vitest";
|
||||
|
||||
export async function selectAntOption(labelText: string, optionText: string) {
|
||||
const label = screen.getByText(labelText);
|
||||
const select =
|
||||
label.closest(".ant-form-item")?.querySelector(".ant-select") ??
|
||||
label.closest(".ant-collapse-item")?.querySelector(".ant-select") ??
|
||||
label.closest("div")?.querySelector(".ant-select") ??
|
||||
null;
|
||||
|
||||
act(() => {
|
||||
fireEvent.mouseDown(select!.querySelector(".ant-select-selector")!);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelectorAll(".ant-select-item-option").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const option = Array.from(document.querySelectorAll(".ant-select-item-option")).find((el) =>
|
||||
el.textContent?.includes(optionText),
|
||||
);
|
||||
expect(option).toBeTruthy();
|
||||
act(() => {
|
||||
fireEvent.click(option!);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue