From c643f6d1af0ec75d6dc9c8a4d1df80aec6a2ceba Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Fri, 10 Jul 2026 20:11:25 +0000 Subject: [PATCH] feat(proxy): move secure share into create-key flow as one-time link Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/routes/allowlist.py | 1 + .../secure_share/secure_share_endpoints.py | 7 +- .../test_secure_share_endpoints.py | 28 ++- .../_components/SecureShareCreate.tsx | 163 ------------------ .../src/app/(dashboard)/secure-share/page.tsx | 34 ---- .../(dashboard)/secure-share/view/page.tsx | 2 +- .../src/components/leftnav.tsx | 8 - .../organisms/create_key_button.tsx | 6 +- .../secure_share/SecureShareLinkButton.tsx | 143 +++++++++++++++ .../secure_share}/SecureShareView.tsx | 0 .../secure_share}/crypto.test.ts | 0 .../secure_share}/crypto.ts | 0 .../src/utils/migratedPages.ts | 1 - 13 files changed, 182 insertions(+), 211 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/secure-share/page.tsx create mode 100644 ui/litellm-dashboard/src/components/secure_share/SecureShareLinkButton.tsx rename ui/litellm-dashboard/src/{app/(dashboard)/secure-share/_components => components/secure_share}/SecureShareView.tsx (100%) rename ui/litellm-dashboard/src/{app/(dashboard)/secure-share/_components => components/secure_share}/crypto.test.ts (100%) rename ui/litellm-dashboard/src/{app/(dashboard)/secure-share/_components => components/secure_share}/crypto.ts (100%) diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index b67f7d42127..3e4ac61b637 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -31,6 +31,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = ( "/oauth/", "/invitation/", "/jwt/", + "/secure_share/", # Models & routing config "/model/", "/v1/model/info", diff --git a/litellm/proxy/secure_share/secure_share_endpoints.py b/litellm/proxy/secure_share/secure_share_endpoints.py index baf26c4fe49..4903bf96604 100644 --- a/litellm/proxy/secure_share/secure_share_endpoints.py +++ b/litellm/proxy/secure_share/secure_share_endpoints.py @@ -11,8 +11,11 @@ alongside an expiry. The plaintext secret and the password never leave the client, so a database or server compromise yields ciphertext without the password required to open it. +Links are one-time: a successful GET returns the ciphertext once and deletes +the row, so the encrypted payload is exposed to the network at most once. + POST /secure_share/create - store an encrypted share (proxy admin only) -GET /secure_share/{share_id} - fetch an unexpired share (admins + internal users) +GET /secure_share/{share_id} - fetch and consume a share once (admins + internal users) DELETE /secure_share/{share_id} - revoke a share early (proxy admin only) """ @@ -178,8 +181,8 @@ async def get_secure_share( raise HTTPException(status_code=404, detail={"error": "Secure share not found."}) share = SecureShareGetResponse.model_validate(row, from_attributes=True) + await repository.table.delete(where={"share_id": share_id}) if _is_expired(share.expires_at): - await repository.table.delete(where={"share_id": share_id}) raise HTTPException(status_code=status.HTTP_410_GONE, detail={"error": "Secure share has expired."}) return share diff --git a/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py b/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py index 6b0ebc6eb4e..62ccfc4fb4f 100644 --- a/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py +++ b/tests/test_litellm/proxy/secure_share/test_secure_share_endpoints.py @@ -169,7 +169,33 @@ async def test_get_returns_unexpired_share_for_allowed_roles(role: LitellmUserRo assert result.share_id == "share-1" assert result.ciphertext == _b64(b"c") - table.delete.assert_not_called() + table.delete.assert_awaited_once_with(where={"share_id": "share-1"}) + + +@pytest.mark.asyncio +async def test_get_is_one_time_second_read_returns_404(): + expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + table = _fake_table() + table.find_unique.side_effect = [ + SimpleNamespace( + share_id="share-1", + ciphertext=_b64(b"c"), + salt=_b64(b"s"), + iv=_b64(b"i"), + expires_at=expires_at, + created_by="admin-user", + ), + None, + ] + + with _patched_prisma(table): + first = await get_secure_share(share_id="share-1", user_api_key_dict=_admin()) + with pytest.raises(HTTPException) as exc: + await get_secure_share(share_id="share-1", user_api_key_dict=_admin()) + + assert first.share_id == "share-1" + table.delete.assert_awaited_once_with(where={"share_id": "share-1"}) + assert exc.value.status_code == 404 @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx b/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx deleted file mode 100644 index bd5bc13e7e1..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareCreate.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { CopyOutlined, LockOutlined } from "@ant-design/icons"; -import { Button, Card, Input, Select, Typography } from "antd"; -import React, { useState } from "react"; -import { createSecureShareCall } from "@/components/networking"; -import NotificationManager from "@/components/molecules/notifications_manager"; -import { encryptSecret } from "./crypto"; - -const { Title, Text, Paragraph } = Typography; - -const EXPIRY_OPTIONS = [ - { value: "1h", label: "1 hour" }, - { value: "6h", label: "6 hours" }, - { value: "1d", label: "1 day" }, - { value: "7d", label: "7 days" }, -]; - -const MIN_PASSWORD_LENGTH = 8; - -interface SecureShareCreateProps { - accessToken: string | null; -} - -interface CreatedShare { - link: string; - expiresAt: string; -} - -function buildShareLink(shareId: string): string { - const base = window.location.href.split(/[?#]/)[0].replace(/\/$/, ""); - return `${base}/view?id=${encodeURIComponent(shareId)}`; -} - -const SecureShareCreate: React.FC = ({ accessToken }) => { - const [secret, setSecret] = useState(""); - const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [expiry, setExpiry] = useState("1d"); - const [isSubmitting, setIsSubmitting] = useState(false); - const [created, setCreated] = useState(null); - - const reset = () => { - setSecret(""); - setPassword(""); - setConfirmPassword(""); - setExpiry("1d"); - setCreated(null); - }; - - const handleCreate = async () => { - if (!accessToken) { - NotificationManager.error("You must be logged in to create a secure share."); - return; - } - if (secret.trim().length === 0) { - NotificationManager.error("Enter the credential you want to share."); - return; - } - if (password.length < MIN_PASSWORD_LENGTH) { - NotificationManager.error(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); - return; - } - if (password !== confirmPassword) { - NotificationManager.error("Passwords do not match."); - return; - } - - setIsSubmitting(true); - try { - const encrypted = await encryptSecret(secret, password); - const payload = { ...encrypted, expiry }; - const response = await createSecureShareCall(accessToken, payload); - setCreated({ link: buildShareLink(response.share_id), expiresAt: response.expires_at }); - NotificationManager.success("Secure share created. The secret was encrypted in your browser."); - } catch (error) { - NotificationManager.fromBackend(error); - } finally { - setIsSubmitting(false); - } - }; - - const copyLink = async () => { - if (!created) return; - await navigator.clipboard.writeText(created.link); - NotificationManager.success("Link copied to clipboard."); - }; - - if (created) { - return ( - - - <LockOutlined /> Secure share created - - - Send this link to the recipient. Share the password separately (not in the same channel). The recipient must - be logged in as a proxy admin or internal user to open it. - -
- - -
- - Expires at {new Date(created.expiresAt).toLocaleString()} - - -
- ); - } - - return ( - - - <LockOutlined /> Secure Share - - - Share a credential over a temporary, end-to-end encrypted link. The secret is encrypted in your browser with a - key derived from the password you choose; the server only ever stores ciphertext. - - -
- Credential - setSecret(e.target.value)} - placeholder="Paste the API key or secret to share" - /> -
- -
- Password - setPassword(e.target.value)} - placeholder={`At least ${MIN_PASSWORD_LENGTH} characters`} - /> -
- -
- Confirm password - setConfirmPassword(e.target.value)} - placeholder="Re-enter the password" - /> -
- -
- Expires after - + + + + + ) : ( + <> + + + Send this link to the recipient and give them the password out-of-band. It reveals the key once, then + expires. + + +
+ + +
+ + )} + + + ); +}; + +export default SecureShareLinkButton; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareView.tsx b/ui/litellm-dashboard/src/components/secure_share/SecureShareView.tsx similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/SecureShareView.tsx rename to ui/litellm-dashboard/src/components/secure_share/SecureShareView.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.test.ts b/ui/litellm-dashboard/src/components/secure_share/crypto.test.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.test.ts rename to ui/litellm-dashboard/src/components/secure_share/crypto.test.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.ts b/ui/litellm-dashboard/src/components/secure_share/crypto.ts similarity index 100% rename from ui/litellm-dashboard/src/app/(dashboard)/secure-share/_components/crypto.ts rename to ui/litellm-dashboard/src/components/secure_share/crypto.ts diff --git a/ui/litellm-dashboard/src/utils/migratedPages.ts b/ui/litellm-dashboard/src/utils/migratedPages.ts index 73466d03773..b0dfb46e312 100644 --- a/ui/litellm-dashboard/src/utils/migratedPages.ts +++ b/ui/litellm-dashboard/src/utils/migratedPages.ts @@ -49,7 +49,6 @@ export const MIGRATED_PAGES: Record = { users: "users", teams: "teams", organizations: "organizations", - "secure-share": "secure-share", }; function uiBase(): string {