fix(ui): clear the shared ID-JAG leg-1 columns on an auth type switch

ID-JAG and OAuth token exchange both persist leg 1 in token_exchange_endpoint,
audience and subject_token_type, but the edit form treated all three as owned
by token exchange alone. Converting an ID-JAG server to another auth type left
a stale endpoint and audience behind, and switching token exchange to ID-JAG
wiped the values the new mode was about to reuse, which the save path then
rejected as a half-configured server.

The three shared columns now clear when a server leaves the pair rather than
when it leaves one member of it, and the token-exchange-only clearing narrows
to token_exchange_profile, the one field that mode alone owns
This commit is contained in:
Yassin Kortam 2026-07-31 11:22:30 -07:00
parent f54f92437b
commit 38a68152ac
2 changed files with 86 additions and 1 deletions

View file

@ -432,6 +432,86 @@ 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",
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
}}
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");
expect(payload.subject_token_type).toBe("urn:ietf:params:oauth:token-type:saml2");
});
it("clears the shared leg-1 endpoint and audience when switching ID-JAG to an auth type that uses neither", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({
...interactiveOAuthServer,
auth_type: "none",
});
render(
<MCPServerEdit
mcpServer={{
...interactiveOAuthServer,
auth_type: "oauth2_id_jag",
token_exchange_endpoint: "https://idp.example.com/oauth2/token",
audience: "api://existing-resource",
subject_token_type: "urn:ietf:params:oauth:token-type:saml2",
}}
accessToken="access-token"
onCancel={vi.fn()}
onSuccess={vi.fn()}
availableAccessGroups={[]}
/>,
);
await selectAntOption("Authentication", "None");
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("none");
expect(payload.token_exchange_endpoint).toBeNull();
expect(payload.audience).toBeNull();
expect(payload.subject_token_type).toBeNull();
});
it("keeps oauth2 endpoint overrides when the auth type is unchanged", async () => {
vi.mocked(networking.updateMCPServer).mockResolvedValue({ ...interactiveOAuthServer });

View file

@ -70,6 +70,7 @@ const AUTH_TYPES_REQUIRING_CREDENTIALS = [
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> = ({
@ -858,9 +859,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, subject_token_type: 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 }
? { token_exchange_profile: null }
: {}),
server_id: mcpServer.server_id,
mcp_info: {