From 23ab246e92287aee5860432654bc33ab97044213 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Fri, 10 Jul 2026 21:29:19 +0000 Subject: [PATCH] feat(proxy): share virtual keys via password.link one-time link Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 10 ++ .../key_management_endpoints.py | 111 ++++++++++++ .../management_helpers/password_link_share.py | 130 ++++++++++++++ .../test_password_link_share.py | 161 ++++++++++++++++++ .../assets/logos/password_link_white.svg | 9 + .../agents/_components/add_agent_form.tsx | 2 +- .../src/components/networking.tsx | 13 ++ .../organisms/create_key_button.tsx | 2 +- .../shared/CreatedKeyDisplay.test.tsx | 42 +++++ .../components/shared/CreatedKeyDisplay.tsx | 94 +++++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 84 +++++++++ 11 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 litellm/proxy/management_helpers/password_link_share.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_password_link_share.py create mode 100644 ui/litellm-dashboard/public/assets/logos/password_link_white.svg diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 43ddf302692..c681078253a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1881,6 +1881,16 @@ class BlockModelRequest(LiteLLMPydanticObjectBase): model_id: str # required +class KeyShareRequest(LiteLLMPydanticObjectBase): + key: str + expiration_hours: int = Field(default=24, ge=1, le=500) + max_views: int = Field(default=1, ge=1, le=100) + + +class KeyShareResponse(LiteLLMPydanticObjectBase): + share_link: str + + class AddTeamCallback(LiteLLMPydanticObjectBase): callback_name: str callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = "success_and_failure" diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index bf64f537c7f..2a62bc96cef 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -25,6 +25,7 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, cast import fastapi import yaml from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status +from typing_extensions import assert_never import litellm from litellm._logging import verbose_proxy_logger @@ -5871,6 +5872,116 @@ async def _check_key_admin_access( ) +@router.post( + "/key/share", + tags=["key management"], + dependencies=[Depends(user_api_key_auth)], + response_model=KeyShareResponse, +) +@management_endpoint_wrapper +async def share_key_via_password_link( + data: KeyShareRequest, + http_request: Request, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +) -> KeyShareResponse: + """ + Create a one-time, self-destructing password.link secret for a virtual key and return the shareable link. + + Encrypts the key client-side (the proxy never uploads plaintext) and stores it on password.link, which + serves a link that reveals the key once and then deletes it. Send the returned link to the recipient. + + Requires `PASSWORD_LINK_API_KEY` (a password.link private API key) to be set on the proxy. Admin-only: + only proxy admins, team admins, or org admins for the key's team can share it. + + Parameters: + - key: str - The virtual key to share (sk-... or its hashed value) + - expiration_hours: int - Hours until the link expires (1-500, default 24) + - max_views: int - How many times the link can be viewed (1-100, default 1) + """ + from litellm.llms.custom_httpx.http_handler import get_async_httpx_client + from litellm.proxy.management_helpers.password_link_share import ( + HttpResponse, + PasswordLinkError, + PasswordLinkShare, + create_password_link_secret, + ) + from litellm.proxy.proxy_server import hash_token, prisma_client, user_api_key_cache + from litellm.secret_managers.main import get_secret_str + from litellm.types.llms.custom_http import httpxSpecialProvider + + if prisma_client is None: + raise ProxyException( + message=CommonProxyErrors.db_not_connected_error.value, + type=ProxyErrorTypes.no_db_connection, + param="key", + code=status.HTTP_400_BAD_REQUEST, + ) + + if not is_valid_api_key(data.key): + raise ProxyException( + message="Invalid key format.", + type=ProxyErrorTypes.bad_request_error, + param="key", + code=status.HTTP_400_BAD_REQUEST, + ) + + hashed_token = hash_token(token=data.key) if data.key.startswith("sk-") else data.key + + await _check_key_admin_access( + user_api_key_dict=user_api_key_dict, + hashed_token=hashed_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + route="/key/share", + ) + + existing_record = await VerificationTokenRepository(prisma_client).table.find_unique(where={"token": hashed_token}) + if existing_record is None: + raise ProxyException( + message=f"Key not found: {hashed_token}", + type=ProxyErrorTypes.not_found_error, + param="key", + code=status.HTTP_404_NOT_FOUND, + ) + + api_key = get_secret_str("PASSWORD_LINK_API_KEY") + if not api_key: + raise ProxyException( + message="password.link sharing is not configured. Set PASSWORD_LINK_API_KEY on the proxy.", + type=ProxyErrorTypes.bad_request_error, + param="PASSWORD_LINK_API_KEY", + code=status.HTTP_400_BAD_REQUEST, + ) + api_base = get_secret_str("PASSWORD_LINK_API_BASE") or "https://password.link" + + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.SecretManager) + + async def _poster(url: str, headers: dict[str, str], body: dict[str, object]) -> HttpResponse: + return await client.post(url=url, headers=headers, json=body) + + result = await create_password_link_secret( + secret=data.key, + api_key=api_key, + poster=_poster, + api_base=api_base, + expiration_hours=data.expiration_hours, + max_views=data.max_views, + ) + + match result: + case PasswordLinkShare(): + return KeyShareResponse(share_link=result.share_link) + case PasswordLinkError(): + raise ProxyException( + message=result.message, + type=ProxyErrorTypes.internal_server_error, + param="key", + code=status.HTTP_502_BAD_GATEWAY, + ) + case _: + assert_never(result) + + @router.post("/key/block", tags=["key management"], dependencies=[Depends(user_api_key_auth)]) @management_endpoint_wrapper async def block_key( diff --git a/litellm/proxy/management_helpers/password_link_share.py b/litellm/proxy/management_helpers/password_link_share.py new file mode 100644 index 00000000000..fee2d10b258 --- /dev/null +++ b/litellm/proxy/management_helpers/password_link_share.py @@ -0,0 +1,130 @@ +import base64 +import json +import secrets +import string +from typing import Awaitable, Callable, Literal, Optional, Protocol, Union + +import httpx +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from pydantic import BaseModel, ValidationError + +_PASSWORD_PART_LENGTH = 18 +_PBKDF2_ITERATIONS = 10000 +_DERIVED_KEY_BYTES = 32 +_GCM_IV_BYTES = 16 +_GCM_SALT_BYTES = 8 +_GCM_TAG_BYTES = 8 +_KEY_SIZE_BITS = 256 +_PART_ALPHABET = string.ascii_letters + string.digits +_DEFAULT_API_BASE = "https://password.link" +_CREATED_STATUS = 201 + + +class PasswordLinkShare(BaseModel): + status: Literal["ok"] = "ok" + share_link: str + secret_id: str + + +class PasswordLinkError(BaseModel): + status: Literal["error"] = "error" + message: str + + +PasswordLinkResult = Union[PasswordLinkShare, PasswordLinkError] + + +class _SecretData(BaseModel): + id: str + domain: Optional[str] = None + + +class _CreateSecretResponse(BaseModel): + data: _SecretData + + +class HttpResponse(Protocol): + @property + def status_code(self) -> int: ... + + def json(self) -> object: ... + + +Poster = Callable[[str, dict[str, str], dict[str, object]], Awaitable[HttpResponse]] + + +def _b64(raw: bytes) -> str: + return base64.b64encode(raw).decode("ascii") + + +def _random_part() -> str: + return "".join(secrets.choice(_PART_ALPHABET) for _ in range(_PASSWORD_PART_LENGTH)) + + +def _sjcl_gcm_ciphertext(passphrase: str, plaintext: str) -> str: + salt = secrets.token_bytes(_GCM_SALT_BYTES) + iv = secrets.token_bytes(_GCM_IV_BYTES) + derived = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=_DERIVED_KEY_BYTES, + salt=salt, + iterations=_PBKDF2_ITERATIONS, + ).derive(passphrase.encode("utf-8")) + encryptor = Cipher(algorithms.AES(derived), modes.GCM(iv)).encryptor() + encryptor.authenticate_additional_data(b"") + body = encryptor.update(plaintext.encode("utf-8")) + encryptor.finalize() + ciphertext = body + encryptor.tag[:_GCM_TAG_BYTES] + payload = { + "iv": _b64(iv), + "v": 1, + "iter": _PBKDF2_ITERATIONS, + "ks": _KEY_SIZE_BITS, + "ts": _GCM_TAG_BYTES * 8, + "mode": "gcm", + "adata": "", + "cipher": "aes", + "salt": _b64(salt), + "ct": _b64(ciphertext), + } + return json.dumps(payload, separators=(",", ":")) + + +async def create_password_link_secret( + *, + secret: str, + api_key: str, + poster: Poster, + api_base: str = _DEFAULT_API_BASE, + expiration_hours: int = 24, + max_views: int = 1, +) -> PasswordLinkResult: + private_part = _random_part() + public_part = _random_part() + ciphertext = _b64(_sjcl_gcm_ciphertext(private_part + public_part, secret).encode("utf-8")) + request_body: dict[str, object] = { + "ciphertext": ciphertext, + "password_part_private": _b64(private_part.encode("utf-8")), + "expiration": expiration_hours, + "max_views": max_views, + } + base = api_base.rstrip("/") + headers = {"Authorization": f"ApiKey {api_key}", "Content-Type": "application/json"} + try: + response = await poster(f"{base}/api/secrets", headers, request_body) + except httpx.HTTPError as exc: + return PasswordLinkError(message=f"Failed to reach password.link: {exc}") + + if response.status_code != _CREATED_STATUS: + return PasswordLinkError(message=f"password.link returned status {response.status_code}") + + try: + parsed = _CreateSecretResponse.model_validate(response.json()) + except (ValueError, ValidationError) as exc: + return PasswordLinkError(message=f"Unexpected password.link response: {exc}") + + domain = (parsed.data.domain or base).rstrip("/") + public_b64 = _b64(public_part.encode("utf-8")) + share_link = f"{domain}/{parsed.data.id}/#{public_b64}" + return PasswordLinkShare(share_link=share_link, secret_id=parsed.data.id) diff --git a/tests/test_litellm/proxy/management_helpers/test_password_link_share.py b/tests/test_litellm/proxy/management_helpers/test_password_link_share.py new file mode 100644 index 00000000000..e81d8650024 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_password_link_share.py @@ -0,0 +1,161 @@ +import base64 +import json + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from litellm.proxy.management_helpers.password_link_share import ( + PasswordLinkError, + PasswordLinkShare, + create_password_link_secret, +) + + +class _FakeResponse: + def __init__(self, status_code: int, payload: object) -> None: + self._status_code = status_code + self._payload = payload + + @property + def status_code(self) -> int: + return self._status_code + + def json(self) -> object: + return self._payload + + +def _decrypt_sjcl(passphrase: str, sjcl_json: str) -> str: + payload = json.loads(sjcl_json) + salt = base64.b64decode(payload["salt"]) + iv = base64.b64decode(payload["iv"]) + tag_bytes = payload["ts"] // 8 + ct_and_tag = base64.b64decode(payload["ct"]) + ciphertext, tag = ct_and_tag[:-tag_bytes], ct_and_tag[-tag_bytes:] + derived = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=payload["iter"], + ).derive(passphrase.encode("utf-8")) + decryptor = Cipher(algorithms.AES(derived), modes.GCM(iv, tag, min_tag_length=tag_bytes)).decryptor() + decryptor.authenticate_additional_data(b"") + return (decryptor.update(ciphertext) + decryptor.finalize()).decode("utf-8") + + +@pytest.mark.asyncio +async def test_creates_decryptable_one_time_link() -> None: + secret = "sk-super-secret-value-1234567890" + captured: dict[str, object] = {} + + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + captured["url"] = url + captured["headers"] = headers + captured["body"] = body + return _FakeResponse(201, {"data": {"id": "abc123", "domain": "https://password.link"}}) + + result = await create_password_link_secret( + secret=secret, + api_key="private_key_test", + poster=poster, + expiration_hours=12, + max_views=1, + ) + + assert isinstance(result, PasswordLinkShare) + assert result.secret_id == "abc123" + assert captured["url"] == "https://password.link/api/secrets" + + headers = captured["headers"] + assert isinstance(headers, dict) + assert headers["Authorization"] == "ApiKey private_key_test" + + body = captured["body"] + assert isinstance(body, dict) + assert body["expiration"] == 12 + assert body["max_views"] == 1 + + assert result.share_link.startswith("https://password.link/abc123/#") + + public_part = base64.b64decode(result.share_link.split("#", 1)[1]).decode("utf-8") + private_part = base64.b64decode(str(body["password_part_private"])).decode("utf-8") + sjcl_json = base64.b64decode(str(body["ciphertext"])).decode("utf-8") + + assert _decrypt_sjcl(private_part + public_part, sjcl_json) == secret + + parsed = json.loads(sjcl_json) + assert parsed["mode"] == "gcm" + assert parsed["ks"] == 256 + assert parsed["iter"] == 10000 + assert parsed["ts"] == 64 + + +@pytest.mark.asyncio +async def test_ciphertext_does_not_leak_plaintext() -> None: + secret = "sk-leak-canary-value" + captured: dict[str, object] = {} + + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + captured["body"] = body + return _FakeResponse(201, {"data": {"id": "id1"}}) + + await create_password_link_secret(secret=secret, api_key="k", poster=poster) + + body = captured["body"] + assert isinstance(body, dict) + assert secret not in json.dumps(body) + + +@pytest.mark.asyncio +async def test_uses_api_base_when_response_has_no_domain() -> None: + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + return _FakeResponse(201, {"data": {"id": "xyz"}}) + + result = await create_password_link_secret( + secret="sk-value", + api_key="k", + poster=poster, + api_base="https://vault.example.com/", + ) + + assert isinstance(result, PasswordLinkShare) + assert result.share_link.startswith("https://vault.example.com/xyz/#") + + +@pytest.mark.asyncio +async def test_non_created_status_returns_error() -> None: + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + return _FakeResponse(403, {"error": {"message": "invalid key"}}) + + result = await create_password_link_secret(secret="sk-value", api_key="bad", poster=poster) + + assert isinstance(result, PasswordLinkError) + assert "403" in result.message + + +@pytest.mark.asyncio +async def test_network_failure_returns_error() -> None: + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + raise httpx.ConnectError("connection refused") + + result = await create_password_link_secret(secret="sk-value", api_key="k", poster=poster) + + assert isinstance(result, PasswordLinkError) + assert "connection refused" in result.message + + +@pytest.mark.asyncio +async def test_each_link_uses_fresh_password_parts() -> None: + bodies: list[dict[str, object]] = [] + + async def poster(url: str, headers: dict[str, str], body: dict[str, object]) -> _FakeResponse: + bodies.append(body) + return _FakeResponse(201, {"data": {"id": "id"}}) + + await create_password_link_secret(secret="sk-value", api_key="k", poster=poster) + await create_password_link_secret(secret="sk-value", api_key="k", poster=poster) + + assert bodies[0]["password_part_private"] != bodies[1]["password_part_private"] + assert bodies[0]["ciphertext"] != bodies[1]["ciphertext"] diff --git a/ui/litellm-dashboard/public/assets/logos/password_link_white.svg b/ui/litellm-dashboard/public/assets/logos/password_link_white.svg new file mode 100644 index 00000000000..9dad98eb616 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/password_link_white.svg @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 8ca2b5afe16..0c4787dd9c2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -929,7 +929,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok {createdKeyValue && (
- +
)} {assignedKeyAlias && ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 5ec2765c621..1a07b19790f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -881,6 +881,19 @@ export const keyDeleteCall = async (accessToken: string, user_key: string) => { } }; +export interface KeyShareResponse { + share_link: string; +} + +export const keyShareCreateCall = async (accessToken: string, key: string): Promise => { + try { + return await apiClient.post(`/key/share`, { accessToken, body: { key } }); + } catch (error) { + console.error("Failed to create secure share link:", error); + throw error; + } +}; + export const userDeleteCall = async (accessToken: string, userIds: string[]) => { try { return await apiClient.post(`/user/delete`, { accessToken, body: { user_ids: userIds } }); diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index ef2ddab70ed..53a9caea245 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -1738,7 +1738,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp Save your Key {apiKey != null ? ( - + ) : ( Key being created, this might take 30s )} diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx index eb6fe5f50cd..f6c2c873d85 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.test.tsx @@ -7,7 +7,12 @@ vi.mock("@/components/molecules/message_manager", () => ({ default: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), destroy: vi.fn() }, })); +vi.mock("@/components/networking", () => ({ + keyShareCreateCall: vi.fn(), +})); + import MessageManager from "@/components/molecules/message_manager"; +import { keyShareCreateCall } from "@/components/networking"; describe("CreatedKeyDisplay", () => { beforeEach(() => { @@ -64,4 +69,41 @@ describe("CreatedKeyDisplay", () => { expect(screen.getByRole("button", { name: /copy virtual key/i })).toBeInTheDocument(); }); + + it("should not show the share button when no accessToken is provided", () => { + render(); + expect(screen.queryByRole("button", { name: /securely share/i })).not.toBeInTheDocument(); + }); + + it("should show the share button when an accessToken is provided", () => { + render(); + expect(screen.getByRole("button", { name: /securely share/i })).toBeInTheDocument(); + }); + + it("should create and display a share link when the share button is clicked", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.mocked(keyShareCreateCall).mockResolvedValue({ + share_link: "https://password.link/abc/#pub", + }); + + render(); + await user.click(screen.getByRole("button", { name: /securely share/i })); + + expect(keyShareCreateCall).toHaveBeenCalledWith("sk-admin", "sk-test-123"); + expect(await screen.findByText("https://password.link/abc/#pub")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /copy share link/i })).toBeInTheDocument(); + }); + + it("should not display a share link when the share call fails", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + vi.mocked(keyShareCreateCall).mockRejectedValue(new Error("boom")); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + render(); + await user.click(screen.getByRole("button", { name: /securely share/i })); + + expect(keyShareCreateCall).toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: /copy share link/i })).not.toBeInTheDocument(); + consoleError.mockRestore(); + }); }); diff --git a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx index cbfd5b2f7fa..02bfea1fd0a 100644 --- a/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx +++ b/ui/litellm-dashboard/src/components/shared/CreatedKeyDisplay.tsx @@ -2,17 +2,25 @@ import React, { useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; import { Button } from "antd"; import MessageManager from "@/components/molecules/message_manager"; +import { keyShareCreateCall } from "@/components/networking"; interface CreatedKeyDisplayProps { apiKey: string; + accessToken?: string; } +const PASSWORD_LINK_LOGO = "/ui/assets/logos/password_link_white.svg"; +const PASSWORD_LINK_PURPLE = "#65428F"; + /** * Shared component for displaying a newly-created virtual key. * Used on the Virtual Keys page and in the Add Agent wizard. */ -const CreatedKeyDisplay: React.FC = ({ apiKey }) => { +const CreatedKeyDisplay: React.FC = ({ apiKey, accessToken }) => { const [copied, setCopied] = useState(false); + const [linkCopied, setLinkCopied] = useState(false); + const [sharing, setSharing] = useState(false); + const [shareLink, setShareLink] = useState(null); const handleCopy = () => { setCopied(true); @@ -20,6 +28,26 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => { setTimeout(() => setCopied(false), 2000); }; + const handleLinkCopy = () => { + setLinkCopied(true); + MessageManager.success("Share link copied to clipboard"); + setTimeout(() => setLinkCopied(false), 2000); + }; + + const handleShare = async () => { + if (!accessToken) return; + setSharing(true); + try { + const response = await keyShareCreateCall(accessToken, apiKey); + setShareLink(response.share_link); + MessageManager.success("Secure share link created"); + } catch (error) { + console.error("Failed to create secure share link:", error); + } finally { + setSharing(false); + } + }; + return (

@@ -40,11 +68,65 @@ const CreatedKeyDisplay: React.FC = ({ apiKey }) => {

{apiKey}
- - - +
+ + + + + {accessToken && ( + + )} +
+ + {shareLink && ( +
+

+ One-time secure link (reveals the key once, then self-destructs). Send it to the recipient: +

+
+
{shareLink}
+
+ + + +
+ )} ); }; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4ca2f85be2b..5213b506159 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -6828,6 +6828,37 @@ export interface paths { patch?: never; trace?: never; }; + "/key/share": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Share Key Via Password Link + * @description Create a one-time, self-destructing password.link secret for a virtual key and return the shareable link. + * + * Encrypts the key client-side (the proxy never uploads plaintext) and stores it on password.link, which + * serves a link that reveals the key once and then deletes it. Send the returned link to the recipient. + * + * Requires `PASSWORD_LINK_API_KEY` (a password.link private API key) to be set on the proxy. Admin-only: + * only proxy admins, team admins, or org admins for the key's team can share it. + * + * Parameters: + * - key: str - The virtual key to share (sk-... or its hashed value) + * - expiration_hours: int - Hours until the link expires (1-500, default 24) + * - max_views: int - How many times the link can be viewed (1-100, default 1) + */ + post: operations["share_key_via_password_link_key_share_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/key/unblock": { parameters: { query?: never; @@ -24361,6 +24392,26 @@ export interface components { /** Keys */ keys?: string[] | null; }; + /** KeyShareRequest */ + KeyShareRequest: { + /** + * Expiration Hours + * @default 24 + */ + expiration_hours: number; + /** Key */ + key: string; + /** + * Max Views + * @default 1 + */ + max_views: number; + }; + /** KeyShareResponse */ + KeyShareResponse: { + /** Share Link */ + share_link: string; + }; /** * KeyUpdateFields * @description Allowlist of bulk-broadcastable fields for /team/key/bulk_update; `extra="forbid"` blocks RBAC/ownership/scope mutations even by team admins. @@ -42204,6 +42255,39 @@ export interface operations { }; }; }; + share_key_via_password_link_key_share_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["KeyShareRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["KeyShareResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; unblock_key_key_unblock_post: { parameters: { query?: never;