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>
This commit is contained in:
devin-ai-integration[bot] 2026-10-09 11:53:10 -07:00 • committed by GitHub
parent cddc7cde97
commit f2f8df2858
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 216 additions and 1 deletions

View file

@ -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<Response>>();
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) => (
<QueryClientProvider client={queryClient}>
{open && (
<CredentialModal
open
mode="edit"
existingCredential={signedCredential}
onCancel={vi.fn()}
onSubmit={vi.fn()}
/>
)}
</QueryClientProvider>
);
respondWith(200, jwks);
const { rerender } = render(modal(true));
await screen.findByLabelText("Public JWKS");
rerender(modal(false));
const rotated = Promise.withResolvers<Response>();
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: {

View file

@ -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({
/>
)}
<InternalIssuerJwks panel={jwksPanelFor(existingCredential, selection)} />
<div className="flex justify-between items-center">
<SimpleTooltip content="Get help on our github">
<a href="https://github.com/BerriAI/litellm/issues" className="text-sm text-primary hover:underline">

View file

@ -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 <p className="text-sm text-muted-foreground">Loading JWKS...</p>;
}
if (jwks.isError) {
return (
<p role="alert" className="text-sm text-destructive">
{extractProxyErrorMessage(jwks.error)}
</p>
);
}
const jwksText = JSON.stringify(jwks.data, null, 2);
return (
<div className="flex flex-col gap-2">
<pre aria-label="Public JWKS" className="max-h-48 overflow-auto rounded-md border bg-muted p-3 text-xs">
{jwksText}
</pre>
<CopyButton value={jwksText} label="Copy JWKS" variant="action" className="self-start" />
</div>
);
};
export default function InternalIssuerJwks({ panel }: { panel: JwksPanel }) {
switch (panel.kind) {
case "hidden":
return null;
case "after_save":
return (
<p className="mb-4 text-sm text-muted-foreground">
Once saved, reopen this credential from LLM Credentials to copy the public JWKS you register in the Claude
Console.
</p>
);
case "saved":
return (
<FieldSet className="mb-4 gap-2">
<FieldLegend variant="label" className="mb-0">
Public JWKS
</FieldLegend>
<FieldDescription>
Register this JWKS with the Issuer URL and Subject above as a federation rule under Settings &gt; 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.
</FieldDescription>
<SavedJwks credentialName={panel.credentialName} />
</FieldSet>
);
}
}

View file

@ -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<string, unknown>) => ({
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",
});
});
});

View file

@ -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 : "";