fix(ui): make the selected ID-JAG client-auth method authoritative and keep chat selection for auto-connected servers

This commit is contained in:
Tin Chi Lo 2026-07-21 16:34:51 -07:00
parent 19e9a31a52
commit e571ac7c3e
7 changed files with 137 additions and 7 deletions

View file

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

View file

@ -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 () => {

View file

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

View file

@ -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 () => {

View file

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

View file

@ -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 () => {

View file

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