mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
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>
This commit is contained in:
parent
34602ff627
commit
23ab246e92
11 changed files with 650 additions and 8 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
130
litellm/proxy/management_helpers/password_link_share.py
Normal file
130
litellm/proxy/management_helpers/password_link_share.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="3150.5407756423706"
|
||||
height="510.99101576141265" viewBox="0 0 3150.5407756423706 510.99101576141265">
|
||||
|
||||
<g transform="scale(7.527038782118522) translate(10, 10)">
|
||||
<defs id="SvgjsDefs26202"></defs><g id="SvgjsG26203" featureKey="nameLeftFeature-0" transform="matrix(1.8475072383880615,0,0,1.8475072383880615,-3.1407615299387497,0.2844576051121326)" fill="#ffffff"><path d="M7 6 c3.28 0 4.82 2.48 4.82 5.1 s-1.54 5.1 -4.82 5.1 l-3.5 0 l0 3.8 l-1.8 0 l0 -14 l5.3 0 z M6.92 14.48 c1.84 0 3.06 -1.42 3.06 -3.38 s-1.22 -3.38 -3.06 -3.38 l-3.42 0 l0 6.76 l3.42 0 z M26.078 20 l-1.22 -2.84 l-6.24 0 l-1.22 2.84 l-1.92 0 l6.16 -14.2 l0.2 0 l6.16 14.2 l-1.92 0 z M19.338 15.48 l4.8 0 l-2.4 -5.54 z M31.456000000000003 18.54 c0.88 0.82 2.7 1.66 4.52 1.66 c2.8 0 4.58 -1.44 4.58 -3.7 c0 -1.84 -0.88 -2.92 -3.92 -4.64 c-2.32 -1.34 -2.88 -1.78 -2.88 -2.66 c0 -0.94 0.88 -1.68 2.36 -1.68 c0.86 0 2 0.38 2.66 0.8 l0.96 -1.44 c-0.94 -0.62 -2.42 -1.08 -3.6 -1.08 c-2.58 0 -4.3 1.48 -4.3 3.42 c0 1.72 0.86 2.58 3.5 4.02 c2.34 1.28 3.3 2.22 3.3 3.24 c0 1.2 -1.04 1.92 -2.66 1.92 c-1.4 0 -2.8 -0.66 -3.5 -1.3 z M44.614000000000004 18.54 c0.88 0.82 2.7 1.66 4.52 1.66 c2.8 0 4.58 -1.44 4.58 -3.7 c0 -1.84 -0.88 -2.92 -3.92 -4.64 c-2.32 -1.34 -2.88 -1.78 -2.88 -2.66 c0 -0.94 0.88 -1.68 2.36 -1.68 c0.86 0 2 0.38 2.66 0.8 l0.96 -1.44 c-0.94 -0.62 -2.42 -1.08 -3.6 -1.08 c-2.58 0 -4.3 1.48 -4.3 3.42 c0 1.72 0.86 2.58 3.5 4.02 c2.34 1.28 3.3 2.22 3.3 3.24 c0 1.2 -1.04 1.92 -2.66 1.92 c-1.4 0 -2.8 -0.66 -3.5 -1.3 z M57.352000000000004 6 l1.88 0 l3.08 9.54 l3.66 -9.74 l0.22 0 l3.7 9.74 l3.06 -9.54 l1.9 0 l-4.7 14.2 l-0.22 0 l-3.84 -10 l-3.86 10 l-0.2 0 z M85.89000000000001 20.2 c-4 0 -7.18 -3.2 -7.18 -7.2 s3.18 -7.2 7.18 -7.2 s7.18 3.2 7.18 7.2 s-3.18 7.2 -7.18 7.2 z M85.89000000000001 18.48 c3.04 0 5.32 -2.38 5.32 -5.48 s-2.28 -5.48 -5.32 -5.48 c-3.06 0 -5.34 2.38 -5.34 5.48 s2.28 5.48 5.34 5.48 z M108.328 11.06 c0 2.26 -1.06 4.2 -3.24 4.84 l3.02 4.1 l-2.16 0 l-2.86 -3.88 l-3.14 0 l0 3.88 l-1.8 0 l0 -14 l5.28 0 c3.3 0 4.9 2.28 4.9 5.06 z M99.94800000000001 7.68 l0 6.74 l3.4 0 c2.26 0 3.18 -1.66 3.18 -3.36 s-0.92 -3.38 -3.18 -3.38 l-3.4 0 z M118.14600000000002 6 c4.64 0 7.2 2.84 7.2 7 s-2.56 7 -7.2 7 l-4.8 0 l0 -14 l4.8 0 z M118.24600000000001 18.28 c3.4 0 5.3 -2.12 5.3 -5.28 c0 -3.18 -1.9 -5.28 -5.3 -5.28 l-3.1 0 l0 10.56 l3.1 0 z"></path></g><g id="SvgjsG26204" featureKey="inlineSymbolFeature-0" transform="matrix(0.7344691157341003,0,0,0.7344691157341003,232.8699359099885,-13.587680065059777)" fill="#ab88ff"><g xmlns="http://www.w3.org/2000/svg"><path d="M20.6,23.2l25.2-4.4c3.1-0.4,6.1-0.4,9.1,0l25.3,4.5v35.5L80,58.9c-9.2,14.4-26.1,23.5-27.3,24.2c-0.6,0.4-1.3,0.6-2.1,0.6 c-0.7,0-1.5-0.2-2.3-0.5c-0.9-0.5-20.3-10.7-27.5-24.4l-0.1-0.3V23.2z M46.1,21.1L23,25.2V58c6.9,12.8,26.1,23,26.3,23.1 c0.7,0.3,1.6,0.4,2.1,0.1l0.1-0.1c0.2-0.1,17.2-9.2,26.3-23.1V25.2l-23.2-4.1C51.9,20.8,48.9,20.7,46.1,21.1z"></path><path d="M54.1,23.8c-0.9-0.1-1.9-0.2-2.9-0.2c-1.6,0-3.2,0-4.8,0.2l-20.6,3.6v29.8l-0.1,0.1l0.1,0.1v0.3l0.4,0.6 c0.9,1.2,1.9,2.5,3.1,3.9c1.2,1.4,2.6,2.7,4,4c1.3,1.2,2.7,2.4,4.3,3.7c1.4,1.1,2.9,2.2,4.5,3.5c1.5,1.1,3,2.2,4.7,3.3 c0.8,0.5,1.5,1,2,1.3l1.5,0.7l1.2-0.8c6.6-4.3,15.9-11.2,22.7-20.1l0.5-0.7V27.4L54.1,23.8z M39.8,27.4L28.2,39l0-9.6L39.8,27.4z M28.3,48.6L50.9,26c0.1,0,0.2,0,0.3,0c1,0,1.8,0.1,2.6,0.2l2.6,0.5L28.3,54.8L28.3,48.6z M31.6,61.1l33-33l5.3,0.9L34.7,64.2 C33.6,63.1,32.5,62.1,31.6,61.1z M39.8,68.6l32.7-32.7v6.2L43.3,71.3C42,70.4,40.9,69.5,39.8,68.6z M72.5,56.5 C65.9,65,56.8,71.8,50.4,76L50.3,76l-0.1,0c-0.3-0.2-0.8-0.5-1.2-0.8l23.5-23.5L72.5,56.5L72.5,56.5z"></path></g></g><g id="SvgjsG26205" featureKey="nameRightFeature-0" transform="matrix(1.8475072383880615,0,0,1.8475072383880615,292.08756709895573,0.2844576051121326)" fill="#ffffff"><path d="M12.578 18.28 l5.92 0 l-0.12 1.72 l-7.6 0 l0 -14 l1.8 0 l0 12.28 z M22.916 20 l0 -14 l1.8 0 l0 14 l-1.8 0 z M39.934 6 l1.8 0 l0 14.2 l-0.22 0 l-9.4 -9.98 l0 9.78 l-1.78 0 l0 -14.2 l0.2 0 l9.4 9.98 l0 -9.78 z M55.532000000000004 20 l-4.14 -6.38 l-2.24 2.78 l0 3.6 l-1.8 0 l0 -14 l1.8 0 l0 7.62 l6.22 -7.62 l2.18 0 l-4.94 6.12 l5.02 7.88 l-2.1 0 z"></path></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -929,7 +929,7 @@ const AddAgentForm: React.FC<AddAgentFormProps> = ({ visible, onClose, accessTok
|
|||
</div>
|
||||
{createdKeyValue && (
|
||||
<div className="mt-4 text-left max-w-md mx-auto">
|
||||
<CreatedKeyDisplay apiKey={createdKeyValue} />
|
||||
<CreatedKeyDisplay apiKey={createdKeyValue} accessToken={accessToken ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
{assignedKeyAlias && (
|
||||
|
|
|
|||
|
|
@ -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<KeyShareResponse> => {
|
||||
try {
|
||||
return await apiClient.post<KeyShareResponse>(`/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 } });
|
||||
|
|
|
|||
|
|
@ -1738,7 +1738,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
<Title>Save your Key</Title>
|
||||
<Col numColSpan={1}>
|
||||
{apiKey != null ? (
|
||||
<CreatedKeyDisplay apiKey={apiKey} />
|
||||
<CreatedKeyDisplay apiKey={apiKey} accessToken={accessToken} />
|
||||
) : (
|
||||
<Text>Key being created, this might take 30s</Text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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(<CreatedKeyDisplay apiKey="sk-test-123" />);
|
||||
expect(screen.queryByRole("button", { name: /securely share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the share button when an accessToken is provided", () => {
|
||||
render(<CreatedKeyDisplay apiKey="sk-test-123" accessToken="sk-admin" />);
|
||||
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(<CreatedKeyDisplay apiKey="sk-test-123" accessToken="sk-admin" />);
|
||||
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(<CreatedKeyDisplay apiKey="sk-test-123" accessToken="sk-admin" />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<CreatedKeyDisplayProps> = ({ apiKey }) => {
|
||||
const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey, accessToken }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
|
||||
const handleCopy = () => {
|
||||
setCopied(true);
|
||||
|
|
@ -20,6 +28,26 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ 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 (
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
|
|
@ -40,11 +68,65 @@ const CreatedKeyDisplay: React.FC<CreatedKeyDisplayProps> = ({ apiKey }) => {
|
|||
<pre style={{ wordWrap: "break-word", whiteSpace: "normal", margin: 0 }}>{apiKey}</pre>
|
||||
</div>
|
||||
|
||||
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
|
||||
<Button type="primary" style={{ marginTop: 12 }}>
|
||||
{copied ? "Copied!" : "Copy Virtual Key"}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
|
||||
<Button type="primary" style={{ marginTop: 12 }}>
|
||||
{copied ? "Copied!" : "Copy Virtual Key"}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
|
||||
{accessToken && (
|
||||
<Button
|
||||
onClick={handleShare}
|
||||
loading={sharing}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
background: PASSWORD_LINK_PURPLE,
|
||||
borderColor: PASSWORD_LINK_PURPLE,
|
||||
color: "#ffffff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>Securely share with</span>
|
||||
<span
|
||||
role="img"
|
||||
aria-label="Password.link"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 86,
|
||||
height: 14,
|
||||
backgroundImage: `url(${PASSWORD_LINK_LOGO})`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "contain",
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shareLink && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
One-time secure link (reveals the key once, then self-destructs). Send it to the recipient:
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: "#f8f8f8",
|
||||
padding: "10px",
|
||||
borderRadius: "5px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<pre style={{ wordWrap: "break-word", whiteSpace: "normal", margin: 0 }}>{shareLink}</pre>
|
||||
</div>
|
||||
<CopyToClipboard text={shareLink} onCopy={handleLinkCopy}>
|
||||
<Button>{linkCopied ? "Copied!" : "Copy Share Link"}</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
84
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
84
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue