mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
cddc7cde97
commit
f2f8df2858
5 changed files with 216 additions and 1 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 > 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 : "";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue