virtual-key-mapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,404 @@
1
+ import base64
2
+ import hashlib
3
+ import hmac
4
+ import logging
5
+ import math
6
+ import secrets
7
+ import time
8
+ import webbrowser
9
+ from dataclasses import dataclass, field
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
+ from typing import Callable
12
+ from urllib.parse import parse_qs, urlencode, urlsplit
13
+
14
+ from ..errors import CredentialError
15
+ from ..profiles import Profile, gateway_origin
16
+ from ..transport import JsonTransport, printable
17
+
18
+
19
+ def team_id(profile: Profile) -> str:
20
+ """The LiteLLM team chosen at browser consent; validated by the litellm-sso auth kind."""
21
+ return profile.auth["team_id"]
22
+
23
+
24
+ def required_text(data: dict, name: str) -> str:
25
+ value = data.get(name)
26
+ if not isinstance(value, str) or not value or len(value) > 65536:
27
+ raise CredentialError(f"Gateway response is missing a valid {name}.")
28
+ if any(ord(char) < 32 for char in value):
29
+ raise CredentialError(f"Gateway returned an invalid {name}.")
30
+ return value
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Discovery:
35
+ """Validated version-1 native LiteLLM OAuth endpoints on the configured gateway origin."""
36
+
37
+ issuer: str
38
+ authorization_endpoint: str
39
+ registration_endpoint: str
40
+ token_endpoint: str
41
+ revocation_endpoint: str
42
+ resource: str
43
+
44
+ @classmethod
45
+ def parse(cls, data: dict, profile: Profile) -> "Discovery":
46
+ if type(data.get("contract_version")) is not int or data["contract_version"] != 1:
47
+ raise CredentialError("Gateway does not expose the supported LiteLLM native login contract v1.")
48
+ for key, required in (
49
+ ("code_challenge_methods_supported", {"S256"}),
50
+ ("response_types_supported", {"code"}),
51
+ ("grant_types_supported", {"authorization_code", "refresh_token"}),
52
+ ("token_endpoint_auth_methods_supported", {"none"}),
53
+ ("revocation_endpoint_auth_methods_supported", {"none"}),
54
+ ):
55
+ values = data.get(key)
56
+ if not isinstance(values, list) or not all(isinstance(v, str) for v in values):
57
+ raise CredentialError(f"Gateway discovery has invalid {key}.")
58
+ if not required.issubset(values):
59
+ raise CredentialError(f"Gateway does not support required {key}.")
60
+ endpoints = {}
61
+ for key in ("issuer", "resource"):
62
+ endpoints[key] = _require_gateway_root(required_text(data, key), key, profile)
63
+ for key in ("authorization_endpoint", "registration_endpoint", "token_endpoint", "revocation_endpoint"):
64
+ value = required_text(data, key)
65
+ _require_gateway_url(value, profile)
66
+ endpoints[key] = value
67
+ return cls(**endpoints)
68
+
69
+
70
+ class IdentityChanged(CredentialError):
71
+ """A renewal returned a grant for another employee or team; the session must not be used any more."""
72
+
73
+
74
+ _SESSION_FIELDS = (
75
+ "gateway", "team_id", "user_id", "client_id", "access_token", "refresh_token",
76
+ "issued_at", "expires_at", "token_endpoint", "revocation_endpoint", "resource",
77
+ "needs_login", "blocked", "retry_after", "refresh_failures",
78
+ )
79
+ _ENDPOINT_FIELDS = ("token_endpoint", "revocation_endpoint", "resource")
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Session:
84
+ """A single employee/team OAuth grant and the endpoints that renew or revoke it; credentials are excluded from repr.
85
+
86
+ Renewal state: ``needs_login`` means the gateway refused the refresh grant, so no further renewal is
87
+ attempted while the access token is still released until it nearly expires. ``blocked`` means a renewal
88
+ changed identity; nothing is released. ``retry_after``/``refresh_failures`` back off temporary failures.
89
+ """
90
+
91
+ gateway: str
92
+ team_id: str
93
+ user_id: str
94
+ client_id: str
95
+ access_token: str = field(repr=False)
96
+ refresh_token: str = field(repr=False)
97
+ issued_at: float
98
+ expires_at: float
99
+ token_endpoint: str
100
+ revocation_endpoint: str
101
+ resource: str
102
+ needs_login: bool = False
103
+ blocked: bool = False
104
+ retry_after: float | None = None
105
+ refresh_failures: int = 0
106
+
107
+ @classmethod
108
+ def from_response(cls, data: dict, profile: Profile, client_id: str, endpoints: "Discovery | Session") -> "Session":
109
+ if required_text(data, "token_type").lower() != "bearer":
110
+ raise CredentialError("Gateway returned an unsupported token type.")
111
+ expiry = data.get("expires_in")
112
+ if type(expiry) not in (int, float) or not math.isfinite(expiry) or expiry <= 0:
113
+ raise CredentialError("Gateway returned an invalid token expiry.")
114
+ now = time.time()
115
+ return cls(
116
+ profile.gateway,
117
+ required_text(data, "team_id"),
118
+ required_text(data, "user_id"),
119
+ client_id,
120
+ required_text(data, "access_token"),
121
+ required_text(data, "refresh_token"),
122
+ now,
123
+ now + expiry,
124
+ endpoints.token_endpoint,
125
+ endpoints.revocation_endpoint,
126
+ endpoints.resource,
127
+ )
128
+
129
+ def check_binding(self, profile: Profile) -> None:
130
+ """Require this grant to belong to the profile's gateway and team, with endpoints on that gateway."""
131
+ if self.gateway != profile.gateway or self.team_id != team_id(profile):
132
+ raise CredentialError(
133
+ f"Credential team does not match profile '{profile.name}'. "
134
+ "Log in again and choose its configured LiteLLM team."
135
+ )
136
+ for name in _ENDPOINT_FIELDS:
137
+ _require_gateway_url(getattr(self, name), profile)
138
+
139
+ @property
140
+ def lifetime(self) -> float:
141
+ return self.expires_at - self.issued_at
142
+
143
+ def to_dict(self) -> dict:
144
+ return {"version": 1, **self.__dict__}
145
+
146
+ @classmethod
147
+ def from_dict(cls, data: dict) -> "Session":
148
+ if set(data) != {"version", *_SESSION_FIELDS} or type(data["version"]) is not int or data["version"] != 1:
149
+ raise CredentialError("Unsupported stored session; log in again.")
150
+ for name in _SESSION_FIELDS[:-4]:
151
+ if name not in ("issued_at", "expires_at", "needs_login"):
152
+ required_text(data, name)
153
+
154
+ def number(value) -> bool:
155
+ return type(value) in (float, int) and math.isfinite(value)
156
+
157
+ if (
158
+ not all(number(data[name]) for name in ("issued_at", "expires_at"))
159
+ or data["issued_at"] > data["expires_at"]
160
+ or any(type(data[name]) is not bool for name in ("needs_login", "blocked"))
161
+ or (data["retry_after"] is not None and not number(data["retry_after"]))
162
+ or type(data["refresh_failures"]) is not int or data["refresh_failures"] < 0
163
+ ):
164
+ raise CredentialError("Invalid stored session expiry/state; log in again.")
165
+ return cls(**{name: data[name] for name in _SESSION_FIELDS})
166
+
167
+
168
+ def _require_gateway_root(value: str, key: str, profile: Profile) -> str:
169
+ """The issuer and resource name the gateway itself; LiteLLM derives them from PROXY_BASE_URL or the request."""
170
+ try:
171
+ origin = gateway_origin(value, profile.allow_http)
172
+ except CredentialError:
173
+ origin = None
174
+ if origin != profile.gateway:
175
+ raise CredentialError(
176
+ f"Gateway advertises {key} '{printable(value, 200)}', not the configured {profile.gateway}. "
177
+ f"The gateway must be served at the host root with PROXY_BASE_URL={profile.gateway} "
178
+ "(or trusted X-Forwarded-* headers); ask the gateway administrator."
179
+ )
180
+ return value
181
+
182
+
183
+ def _require_gateway_url(value: str, profile: Profile) -> None:
184
+ try:
185
+ parsed = urlsplit(value)
186
+ origin = gateway_origin(f"{parsed.scheme}://{parsed.netloc}", profile.allow_http)
187
+ except (ValueError, CredentialError):
188
+ raise CredentialError("Gateway endpoint is invalid.") from None
189
+ if origin != profile.gateway or parsed.username or parsed.password or parsed.query or parsed.fragment:
190
+ raise CredentialError("Gateway endpoint is outside the trusted gateway origin.")
191
+
192
+
193
+ class LoopbackCallback:
194
+ """Receive one state-verified OAuth response on an ephemeral IPv4 loopback listener."""
195
+
196
+ def __init__(self, state: str, notify: Callable[[str], None] | None = None) -> None:
197
+ self.result: str | CredentialError | None = None
198
+ owner = self
199
+
200
+ class CallbackServer(ThreadingHTTPServer):
201
+ def handle_error(self, request, client_address) -> None:
202
+ message = "Warning: callback connection failed; retry login if it does not complete."
203
+ if notify is not None:
204
+ notify(message)
205
+ else:
206
+ logging.getLogger(__name__).warning(message)
207
+
208
+ class Handler(BaseHTTPRequestHandler):
209
+ def setup(self) -> None:
210
+ self.request.settimeout(3)
211
+ super().setup()
212
+
213
+ def log_message(self, *args) -> None:
214
+ pass
215
+
216
+ def do_GET(self) -> None:
217
+ parsed = urlsplit(self.path)
218
+ query = parse_qs(parsed.query, keep_blank_values=True)
219
+ states = query.get("state", [])
220
+ valid = (
221
+ self.headers.get("Host") == f"127.0.0.1:{self.server.server_port}"
222
+ and parsed.path == "/callback"
223
+ and len(states) == 1
224
+ and hmac.compare_digest(states[0].encode(), state.encode())
225
+ )
226
+ if not valid:
227
+ self.send_error(400, "Invalid callback")
228
+ return
229
+ if "error" in query:
230
+ owner.result = CredentialError("Authorization was denied in the browser.")
231
+ elif len(query.get("code", [])) == 1 and query["code"][0]:
232
+ owner.result = query["code"][0]
233
+ else:
234
+ self.send_error(400, "Missing authorization code")
235
+ return
236
+ message = b"Login response received. Return to your terminal."
237
+ self.send_response(200)
238
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
239
+ self.send_header("Cache-Control", "no-store")
240
+ self.send_header("Content-Length", str(len(message)))
241
+ self.end_headers()
242
+ self.wfile.write(message)
243
+
244
+ try:
245
+ self.server = CallbackServer(("127.0.0.1", 0), Handler)
246
+ except OSError:
247
+ raise CredentialError("Cannot open the local login callback listener.") from None
248
+ self.server.timeout = 0.5
249
+ self.redirect_uri = f"http://127.0.0.1:{self.server.server_port}/callback"
250
+
251
+ def wait(self, timeout: float) -> str:
252
+ deadline = time.monotonic() + timeout
253
+ while self.result is None and time.monotonic() < deadline:
254
+ self.server.handle_request()
255
+ if isinstance(self.result, CredentialError):
256
+ raise self.result
257
+ if self.result is None:
258
+ raise CredentialError("Login timed out; run login again.")
259
+ return self.result
260
+
261
+ def close(self) -> None:
262
+ self.server.server_close()
263
+
264
+
265
+ class LiteLLMClient:
266
+ """Consume native LiteLLM SSO; no admin keys, legacy sk-key mapping or team override."""
267
+
268
+ def __init__(self, profile: Profile, transport: JsonTransport | None = None) -> None:
269
+ self.profile = profile
270
+ self.transport = transport or JsonTransport()
271
+
272
+ def discover(self) -> Discovery:
273
+ data = self.transport.request(
274
+ "GET", self.profile.gateway + "/.well-known/litellm-cli-auth"
275
+ )
276
+ return Discovery.parse(data, self.profile)
277
+
278
+ def login(
279
+ self,
280
+ notify: Callable[[str], None],
281
+ *,
282
+ no_browser: bool = False,
283
+ timeout: float = 180,
284
+ ) -> Session:
285
+ """Open SSO and return an employee/team grant; browser consent still selects the team."""
286
+ discovery = self.discover()
287
+ verifier = secrets.token_urlsafe(64)
288
+ challenge = base64.urlsafe_b64encode(
289
+ hashlib.sha256(verifier.encode()).digest()
290
+ ).rstrip(b"=").decode()
291
+ state = secrets.token_urlsafe(32)
292
+ callback = LoopbackCallback(state, notify)
293
+ try:
294
+ registration = self.transport.request(
295
+ "POST",
296
+ discovery.registration_endpoint,
297
+ {
298
+ "client_name": "Virtual Key Mapper",
299
+ "redirect_uris": [callback.redirect_uri],
300
+ "token_endpoint_auth_method": "none",
301
+ "grant_types": ["authorization_code", "refresh_token"],
302
+ "response_types": ["code"],
303
+ },
304
+ )
305
+ client_id = required_text(registration, "client_id")
306
+ if (
307
+ registration.get("token_endpoint_auth_method") != "none"
308
+ or registration.get("redirect_uris") != [callback.redirect_uri]
309
+ ):
310
+ raise CredentialError("Gateway registered an unexpected OAuth client.")
311
+ url = discovery.authorization_endpoint + "?" + urlencode({
312
+ "response_type": "code",
313
+ "client_id": client_id,
314
+ "redirect_uri": callback.redirect_uri,
315
+ "state": state,
316
+ "code_challenge": challenge,
317
+ "code_challenge_method": "S256",
318
+ "resource": discovery.resource,
319
+ })
320
+ notify(
321
+ f"Choose the LiteLLM team for '{self.profile.name}' "
322
+ f"(expected ID: {team_id(self.profile)}) in browser consent."
323
+ )
324
+ if no_browser:
325
+ notify(url)
326
+ elif not webbrowser.open(url):
327
+ raise CredentialError("Cannot open a browser; rerun login with --no-browser.")
328
+ code = callback.wait(timeout)
329
+ response = self.transport.request(
330
+ "POST",
331
+ discovery.token_endpoint,
332
+ {
333
+ "grant_type": "authorization_code",
334
+ "code": code,
335
+ "redirect_uri": callback.redirect_uri,
336
+ "client_id": client_id,
337
+ "code_verifier": verifier,
338
+ "resource": discovery.resource,
339
+ },
340
+ form=True,
341
+ )
342
+ try:
343
+ if "team_id" in response and response["team_id"] is None:
344
+ # LiteLLM issues a grant without a team to a user who belongs to none.
345
+ raise CredentialError(
346
+ f"Your LiteLLM user is not a member of any team. Ask the gateway administrator to add you "
347
+ f"to team '{team_id(self.profile)}', then log in again and choose it at consent."
348
+ )
349
+ return Session.from_response(response, self.profile, client_id, discovery)
350
+ except CredentialError:
351
+ # The gateway already issued this grant; keep no unsaved grant alive.
352
+ self._revoke_unparsed(response, client_id, discovery, notify)
353
+ raise
354
+ finally:
355
+ callback.close()
356
+
357
+ def refresh(self, session: Session) -> Session:
358
+ """Rotate the refresh grant at the endpoint stored at login; never rediscover or follow redirects.
359
+
360
+ Raise GatewayError for transport/HTTP failures (see GatewayError.retryable), CredentialError
361
+ for an unusable response and IdentityChanged for a changed employee/team, after revoking that
362
+ unexpected grant.
363
+ """
364
+ response = self.transport.request(
365
+ "POST",
366
+ session.token_endpoint,
367
+ {
368
+ "grant_type": "refresh_token",
369
+ "refresh_token": session.refresh_token,
370
+ "client_id": session.client_id,
371
+ "resource": session.resource,
372
+ },
373
+ form=True,
374
+ )
375
+ renewed = Session.from_response(response, self.profile, session.client_id, session)
376
+ try:
377
+ renewed.check_binding(self.profile)
378
+ if renewed.user_id != session.user_id:
379
+ raise CredentialError("Renewed credential changed employee identity; log in again.")
380
+ except CredentialError as error:
381
+ try:
382
+ self.revoke(renewed)
383
+ except CredentialError:
384
+ raise IdentityChanged(
385
+ f"{error} Unexpected refresh grant could not be revoked; contact the gateway administrator."
386
+ ) from None
387
+ raise IdentityChanged(str(error)) from None
388
+ return renewed
389
+
390
+ def revoke(self, session: Session) -> None:
391
+ self._revoke(session.revocation_endpoint, session.refresh_token, session.client_id)
392
+
393
+ def _revoke(self, endpoint: str, refresh_token: str, client_id: str) -> None:
394
+ self.transport.request("POST", endpoint, {"token": refresh_token, "client_id": client_id}, form=True)
395
+
396
+ def _revoke_unparsed(self, response: dict, client_id: str, discovery: Discovery, notify: Callable[[str], None]) -> None:
397
+ """Revoke a grant from a token response that was rejected before it became a Session."""
398
+ refresh_token = response.get("refresh_token")
399
+ if not isinstance(refresh_token, str) or not refresh_token:
400
+ return
401
+ try:
402
+ self._revoke(discovery.revocation_endpoint, refresh_token, client_id)
403
+ except CredentialError:
404
+ notify("Warning: the unsaved grant could not be revoked; its access token expires naturally.")