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:
Mubashir Osmani 2026-07-10 21:29:19 +00:00
parent 34602ff627
commit 23ab246e92
11 changed files with 650 additions and 8 deletions

View file

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

View file

@ -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(

View 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)

View file

@ -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"]

View file

@ -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

View file

@ -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 && (

View file

@ -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 } });

View file

@ -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>
)}

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

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