Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/outbound_credentials/v2_token_store.py: 28%
36 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""v2-native per-user OAuth token read store for the ``authorization_code`` mode.
3The raw "inner" store that ``RefreshingTokenStore`` and ``CachedOAuthTokenStore`` wrap: it reads the
4user's persisted credential and returns a typed ``OAuthToken`` (access token, epoch expiry, refresh
5token), validating the decoded credential blob at this boundary so no ``Any`` leaks past it. It does
6not cache or refresh - those are the decorators. This replaces ``V1PerUserTokenStore`` (which handed
7the whole read + cache + refresh to v1's core) as step 1b: the ``read_credential`` collaborator is
8injected, so the DB/decoding plumbing stays testable and out of this seam.
9"""
11from __future__ import annotations
13from collections.abc import Awaitable, Callable, Mapping
14from datetime import datetime, timezone
15from typing import Final
17from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import (
18 OAuthToken,
19)
21CredentialReader = Callable[[str, str], Awaitable["Mapping[str, object] | None"]]
24def _iso_to_epoch(expires_at: str) -> float | None:
25 try:
26 dt = datetime.fromisoformat(expires_at)
27 except ValueError:
28 return None
29 # A timezone-naive expiry is stored as UTC (db.py writes ``datetime.now(timezone.utc)``),
30 # so anchor it to UTC before ``.timestamp()`` - otherwise a non-UTC host would read it as
31 # local time and skew the expiry, diverging from v1's ``_remaining_token_seconds``.
32 if dt.tzinfo is None:
33 dt = dt.replace(tzinfo=timezone.utc)
34 return dt.timestamp()
37def _to_scopes(raw: object) -> tuple[str, ...]:
38 if isinstance(raw, (list, tuple)):
39 return tuple(s for s in raw if isinstance(s, str))
40 return ()
43def _to_oauth_token(payload: Mapping[str, object]) -> OAuthToken | None:
44 access_token: Final = payload.get("access_token")
45 if not isinstance(access_token, str):
46 return None
47 refresh_token: Final = payload.get("refresh_token")
48 expires_at: Final = payload.get("expires_at")
49 binding_proof: Final = payload.get("identity_binding_proof")
50 return OAuthToken(
51 access_token=access_token,
52 expires_at=_iso_to_epoch(expires_at) if isinstance(expires_at, str) else None,
53 refresh_token=refresh_token if isinstance(refresh_token, str) else None,
54 scopes=_to_scopes(payload.get("scopes")),
55 identity_binding_proof=binding_proof if isinstance(binding_proof, str) else None,
56 )
59class V2PerUserTokenStore:
60 """``OAuthTokenStore`` that reads the user's persisted authorization_code credential, typed.
62 The injected ``read_credential`` returns the decoded credential payload for a ``(user, server)``
63 pair, or ``None`` when the user has not completed OAuth. A backing-store outage surfaces as
64 ``TokenStoreUnavailable`` from the reader, which the arm turns into a challenge rather than a
65 500, so ``fetch`` lets it propagate. Refresh is the wrapping ``RefreshingTokenStore``'s job, so
66 the returned token carries ``expires_at`` and ``refresh_token`` for it to act on.
67 """
69 def __init__(self, read_credential: CredentialReader) -> None:
70 self._read_credential = read_credential
72 async def fetch(self, user_id: str, server_id: str) -> OAuthToken | None:
73 if not user_id:
74 return None
75 payload: Final = await self._read_credential(user_id, server_id)
76 if payload is None:
77 return None
78 return _to_oauth_token(payload)