from __future__ import annotations import hashlib import hmac import html import os import re import secrets from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Annotated, Final from urllib.parse import urlencode, urlsplit import httpx from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from pydantic import ConfigDict, Field, SecretStr, TypeAdapter, ValidationError from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy._experimental.mcp_server.oauth_utils import get_request_base_url from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth from litellm.types.llms.base import LiteLLMBaseModel from litellm.types.proxy.auth.auth_checks import UserNotFoundError router: Final = APIRouter() _PREFIX: Final = "/liteadmin/slack/connect/" _COOKIE: Final = "__Host-litellm-slack-connect-" _HEADERS: Final = { "Cache-Control": "no-store", "Referrer-Policy": "same-origin", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", } class LinkDetails(LiteLLMBaseModel): model_config = ConfigDict(frozen=True, strict=True, extra="forbid") workspace_id: str = Field(min_length=1, max_length=64) slack_user_id: str = Field(min_length=1, max_length=64) email: str = Field(min_length=1, max_length=320) class AdminSession(LiteLLMBaseModel): model_config = ConfigDict(frozen=True) user_id: str credential: SecretStr expires_at: float @dataclass(frozen=True, slots=True) class NativeAdminContext: worker_url: str service_token: SecretStr client: httpx.AsyncClient session_user: Callable[[Request], Awaitable[str | None]] load_user: Callable[[str], Awaitable[LiteLLM_UserTable | None]] mint_session: Callable[[LiteLLM_UserTable], AdminSession] async def worker_request(self, token: str, session: AdminSession | None = None) -> httpx.Response: if re.fullmatch(r"[A-Za-z0-9_-]{43}", token) is None: raise HTTPException(410, "Connection link expired. Send connect in Slack for a new link") try: response: Final = await self.client.request( "GET" if session is None else "POST", f"{self.worker_url}/internal/liteadmin/links/{token}", headers={"X-LiteLLM-Admin-Agent-Token": self.service_token.get_secret_value()}, json=None if session is None else { "user_id": session.user_id, "credential": session.credential.get_secret_value(), "expires_at": session.expires_at, }, timeout=15, follow_redirects=False, ) except httpx.HTTPError: raise HTTPException(503, "LiteAdmin is temporarily unavailable") from None if response.status_code == 410: raise HTTPException(410, "Connection link expired. Send connect in Slack for a new link") if response.status_code == 403: raise HTTPException(403, "Connect your own active LiteLLM proxy-admin account with the same email as Slack") if response.status_code != 200: raise HTTPException(503, "LiteAdmin could not verify this connection") return response async def details(self, token: str) -> LinkDetails: response: Final = await self.worker_request(token) try: return LinkDetails.model_validate_json(response.content) except ValidationError: raise HTTPException(503, "LiteAdmin could not verify this connection") from None async def admin(self, user_id: str, details: LinkDetails) -> LiteLLM_UserTable: user: Final = await self.load_user(user_id) if ( user is None or user.user_role != LitellmUserRoles.PROXY_ADMIN.value or not user.user_email or user.user_email.strip().casefold() != details.email.strip().casefold() ): raise HTTPException(403, "Connect your own active LiteLLM proxy-admin account with the same email as Slack") return user def _page(title: str, body: str) -> HTMLResponse: return HTMLResponse( f'' f'
Connect {html.escape(details.email)} to LiteAdmin in your Slack workspace?
" "Model requests and administrative actions will use your own LiteLLM account and current permissions
" f'' "This connection lasts 24 hours. Send disconnect in Slack to remove the saved session
", ) page.set_cookie(_cookie_name(token), csrf, max_age=600, secure=True, httponly=True, samesite="strict", path="/") return page @router.post(_PREFIX + "{token}", include_in_schema=False, response_class=HTMLResponse) async def connect_account( request: Request, token: str, context: Annotated[NativeAdminContext, Depends(native_admin_context)], ) -> Response: base_url: Final = get_request_base_url(request) parsed_base: Final = urlsplit(base_url) origin: Final = f"{parsed_base.scheme}://{parsed_base.netloc}" if parsed_base.scheme != "https" or request.headers.get("Origin") != origin: raise HTTPException(403, "Reopen your private Slack connection link") if request.headers.get("Content-Type", "").split(";", 1)[0] != "application/x-www-form-urlencoded": raise HTTPException(400, "Expected a connection form") form: Final = await request.form(max_fields=1, max_files=0, max_part_size=1024) supplied: Final = form.get("csrf") expected: Final = request.cookies.get(_cookie_name(token), "") if ( not isinstance(supplied, str) or len(expected) != 43 or len(supplied) != 43 or not hmac.compare_digest(supplied.encode(), expected.encode()) ): raise HTTPException(403, "Reopen your private Slack connection link") user_id: Final = await context.session_user(request) if user_id is None: raise HTTPException(401, "Your login expired. Reopen your private Slack connection link") details: Final = await context.details(token) user: Final = await context.admin(user_id, details) await context.worker_request(token, context.mint_session(user)) page: Final = _page( "Account connected", "Return to Slack and ask LiteAdmin to list your teams or check a budget
" ) page.delete_cookie(_cookie_name(token), path="/", secure=True, httponly=True, samesite="strict") return page