From f2f8df2858d5ff02f2a9da115448d0f4aba4ad1d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:53:10 -0700 Subject: [PATCH] feat(ui): show the public JWKS for LiteLLM-signed Anthropic credentials (#45527) * feat(ui): show the public JWKS for LiteLLM-signed Anthropic credentials The edit modal of a saved Anthropic credential whose identity source is the LiteLLM internal issuer now fetches GET /credentials/{name}/jwks and shows the document with a copy button, so an admin can register it in the Claude Console without curl. Picking the internal issuer before the credential is saved shows a hint to reopen it once saved * fix(ui): wait for a fresh JWKS before offering one cached from an earlier open --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../CredentialModal.integration.test.tsx | 99 ++++++++++++++++++- .../components/model_add/CredentialModal.tsx | 4 + .../model_add/InternalIssuerJwks.tsx | 59 +++++++++++ .../model_add/credential_federation.test.ts | 34 +++++++ .../model_add/credential_federation.ts | 21 ++++ 5 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/model_add/InternalIssuerJwks.tsx diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx index 32ed7762e70..4d39e0365cd 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.integration.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { chooseSelectOption } from "../../../tests/test-utils"; import { Providers } from "../provider_info_helpers"; import { CredentialItem } from "../networking"; @@ -443,6 +443,103 @@ describe("CredentialModal with Anthropic workload identity federation", () => { }); }); +describe("CredentialModal public JWKS for a LiteLLM-signed Anthropic credential", () => { + const signedCredential: CredentialItem = { + credential_name: "anthropic-signed", + credential_values: { + anthropic_identity_source: "internal_issuer", + anthropic_issuer_url: "http****", + anthropic_issuer_subject: "lite****", + anthropic_issuer_signing_key_ref: "os.e****", + }, + credential_info: { custom_llm_provider: "Anthropic" }, + }; + const jwks = { keys: [{ kty: "RSA", kid: "kid-1", use: "sig", alg: "RS256", n: "modulus", e: "AQAB" }] }; + const fetchMock = vi.fn<(request: Request) => Promise>(); + const requestedPaths = () => fetchMock.mock.calls.map(([request]) => new URL(request.url).pathname); + const respondWith = (status: number, body: unknown) => + fetchMock.mockImplementation(async () => Response.json(body, { status })); + + beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the JWKS the proxy serves for the saved credential and copies it verbatim", async () => { + const user = userEvent.setup(); + respondWith(200, jwks); + renderModal({ mode: "edit", existingCredential: signedCredential }); + + const shown = await screen.findByLabelText("Public JWKS"); + expect(JSON.parse(shown.textContent ?? "")).toEqual(jwks); + expect(requestedPaths()).toEqual(["/credentials/anthropic-signed/jwks"]); + + await user.click(screen.getByRole("button", { name: "Copy JWKS" })); + + expect(await navigator.clipboard.readText()).toBe(shown.textContent); + }); + + it("waits for a fresh JWKS instead of offering the one cached from an earlier open", async () => { + const rotatedJwks = { keys: [{ ...jwks.keys[0], kid: "kid-2", n: "rotated-modulus" }] }; + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const modal = (open: boolean) => ( + + {open && ( + + )} + + ); + respondWith(200, jwks); + const { rerender } = render(modal(true)); + await screen.findByLabelText("Public JWKS"); + rerender(modal(false)); + + const rotated = Promise.withResolvers(); + fetchMock.mockReturnValue(rotated.promise); + rerender(modal(true)); + + expect(await screen.findByText("Loading JWKS...")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy JWKS" })).not.toBeInTheDocument(); + + rotated.resolve(Response.json(rotatedJwks)); + + expect(JSON.parse((await screen.findByLabelText("Public JWKS")).textContent ?? "")).toEqual(rotatedJwks); + }); + + it("shows why the proxy cannot build the JWKS", async () => { + respondWith(400, { detail: { error: "anthropic_issuer_signing_key_ref did not resolve to a PEM private key" } }); + renderModal({ mode: "edit", existingCredential: signedCredential }); + + expect(await screen.findByRole("alert")).toHaveTextContent( + "anthropic_issuer_signing_key_ref did not resolve to a PEM private key", + ); + expect(screen.queryByRole("button", { name: "Copy JWKS" })).not.toBeInTheDocument(); + }); + + it("asks the admin to save first while the credential is not stored as LiteLLM-signed", async () => { + const user = userEvent.setup(); + renderModal({ mode: "edit", existingCredential: federatedCredential }); + await screen.findByLabelText("Upstream API Base"); + expect(screen.queryByText(/copy the public JWKS/)).not.toBeInTheDocument(); + + await chooseOption(user, /Identity Source/, "Token signed by LiteLLM (internal issuer)"); + + expect(await screen.findByText(/Once saved, reopen this credential/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Public JWKS")).not.toBeInTheDocument(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + const openAIFederatedCredential: CredentialItem = { credential_name: "openai-federated", credential_values: { diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx index 65778e72f2d..81b4fef9a6d 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialModal.tsx @@ -20,6 +20,7 @@ import { Logo } from "@/components/molecules/logo/Logo"; import { resetCredentialFormOnProviderChange, withoutRestrictedFields } from "./credential_form_helpers"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import FederationFields from "./FederationFields"; +import InternalIssuerJwks from "./InternalIssuerJwks"; import { DEFAULT_IDENTITY_SOURCE, inferIdentitySource, type IdentitySourceId } from "./anthropic_federation"; import { buildCreateCredentialValues, @@ -28,6 +29,7 @@ import { federatedProviderOf, inferAuthMethod, isFederatedCredential, + jwksPanelFor, providerFieldValidators, selectionFor, type AuthMethod, @@ -292,6 +294,8 @@ export default function CredentialModal({ /> )} + +
diff --git a/ui/litellm-dashboard/src/components/model_add/InternalIssuerJwks.tsx b/ui/litellm-dashboard/src/components/model_add/InternalIssuerJwks.tsx new file mode 100644 index 00000000000..71aa263c608 --- /dev/null +++ b/ui/litellm-dashboard/src/components/model_add/InternalIssuerJwks.tsx @@ -0,0 +1,59 @@ +import CopyButton from "@/components/shared/CopyButton"; +import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field"; +import { $api } from "@/lib/http/api"; +import { extractProxyErrorMessage } from "@/lib/http/client"; +import type { JwksPanel } from "./credential_federation"; + +const SavedJwks = ({ credentialName }: { credentialName: string }) => { + const jwks = $api.useQuery("get", "/credentials/{credential_name}/jwks", { + params: { path: { credential_name: credentialName } }, + }); + if (jwks.isPending || !jwks.isFetchedAfterMount) { + return

Loading JWKS...

; + } + if (jwks.isError) { + return ( +

+ {extractProxyErrorMessage(jwks.error)} +

+ ); + } + const jwksText = JSON.stringify(jwks.data, null, 2); + return ( +
+
+        {jwksText}
+      
+ +
+ ); +}; + +export default function InternalIssuerJwks({ panel }: { panel: JwksPanel }) { + switch (panel.kind) { + case "hidden": + return null; + case "after_save": + return ( +

+ Once saved, reopen this credential from LLM Credentials to copy the public JWKS you register in the Claude + Console. +

+ ); + case "saved": + return ( +
+ + Public JWKS + + + Register this JWKS with the Issuer URL and Subject above as a federation rule under Settings > Workload + identity in the Claude Console. Then fill in its Federation Rule ID and Organization ID at the top of this + form and update the credential. The JWKS comes from the saved signing key, so update after changing the key + and before copying. + + +
+ ); + } +} diff --git a/ui/litellm-dashboard/src/components/model_add/credential_federation.test.ts b/ui/litellm-dashboard/src/components/model_add/credential_federation.test.ts index e7670c98aa7..a9bc6e833d4 100644 --- a/ui/litellm-dashboard/src/components/model_add/credential_federation.test.ts +++ b/ui/litellm-dashboard/src/components/model_add/credential_federation.test.ts @@ -6,6 +6,7 @@ import { federatedProviderOf, inferAuthMethod, isFederatedCredential, + jwksPanelFor, providerFieldValidators, selectionFor, } from "./credential_federation"; @@ -343,3 +344,36 @@ describe("buildCredentialPatch", () => { expect(patch.credential_values_to_delete.filter((key) => written.includes(key))).toEqual([]); }); }); + +describe("jwksPanelFor", () => { + const storedCredential = (provider: string, values: Record) => ({ + credential_name: "anthropic-wif", + credential_values: values, + credential_info: { custom_llm_provider: provider }, + }); + const storedInternalIssuer = { anthropic_identity_source: "internal_issuer", anthropic_issuer_url: "http****" }; + + it.each([apiKeySelection, tokenFileSelection, keycloakSelection, environmentSelection, openAISelection])( + "hides the panel unless internal issuer federation is selected (%o)", + (selection) => { + expect(jwksPanelFor(storedCredential("Anthropic", storedInternalIssuer), selection)).toEqual({ kind: "hidden" }); + }, + ); + + it.each(["Anthropic", "anthropic"])("serves the stored %s internal issuer credential by name", (provider) => { + expect(jwksPanelFor(storedCredential(provider, storedInternalIssuer), internalIssuerSelection)).toEqual({ + kind: "saved", + credentialName: "anthropic-wif", + }); + }); + + it("asks to save first when nothing the jwks route can serve is stored yet", () => { + expect(jwksPanelFor(null, internalIssuerSelection)).toEqual({ kind: "after_save" }); + expect(jwksPanelFor(storedCredential("Anthropic", storedKeycloak), internalIssuerSelection)).toEqual({ + kind: "after_save", + }); + expect(jwksPanelFor(storedCredential("OpenAI", storedInternalIssuer), internalIssuerSelection)).toEqual({ + kind: "after_save", + }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/model_add/credential_federation.ts b/ui/litellm-dashboard/src/components/model_add/credential_federation.ts index 345dd7b2ac7..10788c84990 100644 --- a/ui/litellm-dashboard/src/components/model_add/credential_federation.ts +++ b/ui/litellm-dashboard/src/components/model_add/credential_federation.ts @@ -1,8 +1,10 @@ import { isMaskedSecret } from "@/utils/maskedSecretUtils"; +import type { CredentialItem } from "../networking"; import { ANTHROPIC_FEDERATION_FIELDS, ANTHROPIC_FEDERATION_VALUE_KEYS, identitySourceById, + inferIdentitySource, isAnthropicProvider, otherIdentitySourceKeys, type IdentitySourceId, @@ -78,6 +80,25 @@ export const providerFieldValidators = (selection: CredentialSelection): Provide const identitySourceOf = (selection: CredentialSelection): IdentitySourceId | null => selection.authMethod === "federation" && selection.provider === "anthropic" ? selection.identitySource : null; +export type JwksPanel = + | { readonly kind: "hidden" } + | { readonly kind: "after_save" } + | { readonly kind: "saved"; readonly credentialName: string }; + +export const jwksPanelFor = (storedCredential: CredentialItem | null, selection: CredentialSelection): JwksPanel => { + if (identitySourceOf(selection) !== "internal_issuer") { + return { kind: "hidden" }; + } + if ( + storedCredential === null || + !isAnthropicProvider(storedCredential.credential_info.custom_llm_provider) || + inferIdentitySource(storedCredential.credential_values) !== "internal_issuer" + ) { + return { kind: "after_save" }; + } + return { kind: "saved", credentialName: storedCredential.credential_name }; +}; + const toStoredType = (field: FederationField | undefined, value: unknown): unknown => { if (field === undefined || isBlank(value)) { return field === undefined ? value : "";