pi-python-core 0.8.1__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,542 @@
1
+ """Explicit OAuth login and refresh. No ambient credentials or automatic browser access.
2
+
3
+ Endpoint/client constants follow Pi v1.0.0. ChatGPT ID token validation also follows
4
+ OpenAI's public sign-in documentation. Applications own credential persistence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ import asyncio
9
+ import base64
10
+ import hashlib
11
+ import math
12
+ import secrets
13
+ import time
14
+ import uuid
15
+ from collections.abc import Callable
16
+ from dataclasses import dataclass, field, replace
17
+ from typing import Any
18
+ from urllib.parse import parse_qs, urlencode, urlsplit
19
+ from ..cancellation import CancelToken
20
+ from ..errors import ConfigurationError, ProviderProtocolError
21
+ from ..tools import invoke
22
+ from .transport import HTTPTransport, ProviderHTTPError, cancellable
23
+ from .openai import account_id
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class OAuthCredential:
28
+ provider: str
29
+ access_token: str = field(repr=False)
30
+ refresh_token: str = field(repr=False)
31
+ expires_at: float
32
+ client_id: str = ""
33
+ scopes: tuple[str, ...] = ()
34
+ account_id: str | None = None
35
+ subject: str | None = None
36
+ id_token: str | None = field(default=None, repr=False)
37
+ host_id: str | None = None
38
+
39
+
40
+ @dataclass
41
+ class OAuthAttempt:
42
+ provider: str
43
+ authorize_url: str = field(repr=False)
44
+ redirect_uri: str
45
+ client_id: str
46
+ verifier: str = field(repr=False)
47
+ state: str = field(repr=False)
48
+ nonce: str = field(repr=False)
49
+ host_id: str | None = None
50
+ expected_subject: str | None = None
51
+ consumed: bool = False
52
+
53
+
54
+ _SETTINGS = {
55
+ "anthropic": (
56
+ "https://claude.ai/oauth/authorize",
57
+ "https://platform.claude.com/v1/oauth/token",
58
+ "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
59
+ "http://localhost:53692/callback",
60
+ "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
61
+ ),
62
+ "openai-codex": (
63
+ "https://auth.openai.com/oauth/authorize",
64
+ "https://auth.openai.com/oauth/token",
65
+ "app_EMoamEEZ73f0CkXaXp7hrann",
66
+ "http://localhost:1455/auth/callback",
67
+ "openid profile email offline_access",
68
+ ),
69
+ "openai-chatgpt": (
70
+ "https://auth.openai.com/api/accounts/authorize",
71
+ "https://auth.openai.com/api/accounts/oauth/token",
72
+ "dynamic_agent_client",
73
+ "http://127.0.0.1:1455/auth/callback",
74
+ "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
75
+ ),
76
+ }
77
+ _RESOURCE = "https://api.openai.com/v1"
78
+
79
+
80
+ class OAuthClient:
81
+ def __init__(self, provider: str, *, transport: HTTPTransport | None = None) -> None:
82
+ if provider not in _SETTINGS:
83
+ raise ConfigurationError("Unknown OAuth provider")
84
+ self.provider = provider
85
+ self.transport = transport or HTTPTransport()
86
+
87
+ def begin(
88
+ self,
89
+ *,
90
+ method: str = "browser",
91
+ host_id: str | None = None,
92
+ credential: OAuthCredential | None = None,
93
+ redirect_uri: str | None = None,
94
+ ) -> OAuthAttempt:
95
+ authorize, _, clientid, redirect, scope = _SETTINGS[self.provider]
96
+ if method not in {"browser", "copy_code"} or (
97
+ method == "copy_code" and self.provider != "anthropic"
98
+ ):
99
+ raise ConfigurationError("copy_code is supported only for Anthropic")
100
+ if method == "copy_code":
101
+ redirect = "https://platform.claude.com/oauth/code/callback"
102
+ if redirect_uri:
103
+ given, expected = urlsplit(redirect_uri), urlsplit(redirect)
104
+ if (
105
+ self.provider != "openai-chatgpt"
106
+ or (given.scheme, given.hostname, given.path)
107
+ != (expected.scheme, expected.hostname, expected.path)
108
+ or given.query
109
+ or given.fragment
110
+ or given.username
111
+ or given.password
112
+ ):
113
+ raise ConfigurationError("Only ChatGPT loopback port may be overridden")
114
+ redirect = redirect_uri
115
+ verifier = secrets.token_urlsafe(48)
116
+ state = verifier if self.provider == "anthropic" else secrets.token_urlsafe(32)
117
+ nonce = secrets.token_urlsafe(32)
118
+ challenge = (
119
+ base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
120
+ .rstrip(b"=")
121
+ .decode()
122
+ )
123
+ params = {
124
+ "client_id": clientid,
125
+ "response_type": "code",
126
+ "redirect_uri": redirect,
127
+ "scope": scope,
128
+ "code_challenge": challenge,
129
+ "code_challenge_method": "S256",
130
+ "state": state,
131
+ }
132
+ expected_subject = None
133
+ if credential:
134
+ if credential.provider != self.provider:
135
+ raise ConfigurationError("Credential belongs to another provider")
136
+ if self.provider == "openai-chatgpt":
137
+ clientid = credential.client_id
138
+ expected_subject = credential.subject
139
+ params["client_id"] = clientid
140
+ if credential.id_token:
141
+ params["id_token_hint"] = credential.id_token
142
+ host_id = host_id or credential.host_id
143
+ if self.provider == "anthropic":
144
+ params["code"] = "true"
145
+ elif self.provider == "openai-codex":
146
+ params.update(
147
+ {
148
+ "id_token_add_organizations": "true",
149
+ "codex_cli_simplified_flow": "true",
150
+ "originator": "pi",
151
+ }
152
+ )
153
+ else:
154
+ if not host_id:
155
+ raise ConfigurationError(
156
+ "Provide a stable host_id (urn:uuid:...) and persist it for this installation"
157
+ )
158
+ try:
159
+ host_id = "urn:uuid:" + str(uuid.UUID(host_id.removeprefix("urn:uuid:")))
160
+ except (ValueError, AttributeError) as exc:
161
+ raise ConfigurationError("Invalid host UUID") from exc
162
+ params.update({"ext_agent_host_id": host_id, "nonce": nonce, "resource": _RESOURCE})
163
+ if clientid == "dynamic_agent_client":
164
+ params["agent_name_hint"] = "pi-python"
165
+ return OAuthAttempt(
166
+ self.provider,
167
+ authorize + "?" + urlencode(params),
168
+ redirect,
169
+ clientid,
170
+ verifier,
171
+ state,
172
+ nonce,
173
+ host_id,
174
+ expected_subject,
175
+ )
176
+
177
+ def _callback(self, attempt: OAuthAttempt, callback: str) -> tuple[str, str]:
178
+ if attempt.provider != self.provider or attempt.consumed:
179
+ raise ConfigurationError("OAuth attempt is invalid or already consumed")
180
+ if self.provider == "anthropic" and "#" in callback and "://" not in callback:
181
+ code, state = callback.strip().split("#", 1)
182
+ values = {"code": code, "state": state}
183
+ else:
184
+ actual, expected = urlsplit(callback.strip()), urlsplit(attempt.redirect_uri)
185
+ if (actual.scheme, actual.netloc, actual.path) != (
186
+ expected.scheme,
187
+ expected.netloc,
188
+ expected.path,
189
+ ) or actual.fragment:
190
+ raise ConfigurationError("OAuth callback URI does not match")
191
+ query = parse_qs(actual.query, keep_blank_values=True)
192
+ if any(len(v) != 1 for v in query.values()):
193
+ raise ConfigurationError("Duplicate OAuth callback parameter")
194
+ values = {k: v[0] for k, v in query.items()}
195
+ if not secrets.compare_digest(values.get("state", ""), attempt.state):
196
+ raise ConfigurationError("OAuth state mismatch")
197
+ if values.get("error"):
198
+ attempt.consumed = True
199
+ raise ConfigurationError("OAuth authorization was declined or failed")
200
+ if not values.get("code"):
201
+ raise ConfigurationError("OAuth callback lacks code")
202
+ clientid = attempt.client_id
203
+ if self.provider == "openai-chatgpt":
204
+ returned = values.get("client_id")
205
+ if clientid == "dynamic_agent_client":
206
+ if not returned or returned == "dynamic_agent_client":
207
+ raise ConfigurationError("Dynamic registration lacks issued client ID")
208
+ clientid = returned
209
+ elif returned and returned != clientid:
210
+ raise ConfigurationError("OAuth client ID changed during reauthorization")
211
+ return values["code"], clientid
212
+
213
+ async def exchange(
214
+ self, attempt: OAuthAttempt, callback: str, cancel: CancelToken | None = None
215
+ ) -> OAuthCredential:
216
+ cancel = cancel or CancelToken()
217
+ code, clientid = self._callback(attempt, callback)
218
+ attempt.consumed = True
219
+ body = {
220
+ "grant_type": "authorization_code",
221
+ "client_id": clientid,
222
+ "code": code,
223
+ "code_verifier": attempt.verifier,
224
+ "redirect_uri": attempt.redirect_uri,
225
+ }
226
+ if self.provider == "anthropic":
227
+ body["state"] = attempt.state
228
+ if self.provider == "openai-chatgpt":
229
+ body["resource"] = _RESOURCE
230
+ data = await self._token(body, cancel)
231
+ result = self._credential(data, clientid)
232
+ if self.provider == "openai-chatgpt":
233
+ claims = await self._verify_id_token(
234
+ data.get("id_token"), clientid, attempt.nonce, cancel
235
+ )
236
+ if attempt.expected_subject and claims["sub"] != attempt.expected_subject:
237
+ raise ConfigurationError("Reauthorization returned a different account")
238
+ result = replace(
239
+ result, subject=claims["sub"], id_token=data["id_token"], host_id=attempt.host_id
240
+ )
241
+ return result
242
+
243
+ async def _token(self, body: dict[str, Any], cancel: CancelToken) -> dict[str, Any]:
244
+ return await self.transport.request_json(
245
+ _SETTINGS[self.provider][1], body, {}, cancel, form=self.provider != "anthropic"
246
+ )
247
+
248
+ def _credential(self, data: dict[str, Any], clientid: str) -> OAuthCredential:
249
+ for key in ("access_token", "refresh_token"):
250
+ if not isinstance(data.get(key), str) or not data[key]:
251
+ raise ProviderProtocolError(f"OAuth response lacks {key}")
252
+ expires = data.get("expires_in")
253
+ if (
254
+ not isinstance(expires, (int, float))
255
+ or isinstance(expires, bool)
256
+ or not math.isfinite(expires)
257
+ or expires <= 0
258
+ ):
259
+ raise ProviderProtocolError("Invalid OAuth expires_in")
260
+ scope = data.get("scope", "")
261
+ if not isinstance(scope, str):
262
+ raise ProviderProtocolError("Invalid OAuth scope")
263
+ scopes = tuple(scope.split())
264
+ if self.provider == "openai-chatgpt" and "chatgpt.tokens.use.direct" not in scopes:
265
+ raise ConfigurationError("OAuth grant lacks chatgpt.tokens.use.direct")
266
+ account = None
267
+ if self.provider == "openai-codex":
268
+ account = account_id(data["access_token"])
269
+ return OAuthCredential(
270
+ self.provider,
271
+ data["access_token"],
272
+ data["refresh_token"],
273
+ time.time() + expires,
274
+ clientid,
275
+ scopes,
276
+ account,
277
+ )
278
+
279
+ async def _verify_id_token(
280
+ self, token: Any, clientid: str, nonce: str | None, cancel: CancelToken
281
+ ) -> dict[str, Any]:
282
+ import jwt
283
+
284
+ if not isinstance(token, str) or not token:
285
+ raise ConfigurationError("ChatGPT sign-in lacks ID token")
286
+ try:
287
+ discovery = await self.transport.get_json(
288
+ "https://auth.openai.com/.well-known/openid-configuration", cancel
289
+ )
290
+ uri = discovery["jwks_uri"]
291
+ if urlsplit(uri).scheme != "https" or urlsplit(uri).hostname != "auth.openai.com":
292
+ raise ConfigurationError("Unexpected OpenAI JWKS origin")
293
+ keys = await self.transport.get_json(uri, cancel)
294
+ header = jwt.get_unverified_header(token)
295
+ if header.get("alg") != "RS256":
296
+ raise ConfigurationError("Unsupported ID token signing algorithm")
297
+ matches = [k for k in keys["keys"] if k.get("kid") == header.get("kid")]
298
+ if len(matches) != 1:
299
+ raise ConfigurationError("ID token signing key is unavailable")
300
+ key = jwt.PyJWK.from_dict(matches[0], algorithm="RS256").key
301
+ claims = jwt.decode(
302
+ token,
303
+ key,
304
+ algorithms=["RS256"],
305
+ audience=clientid,
306
+ issuer="https://auth.openai.com",
307
+ options={"require": ["exp", "iat", "sub", "iss", "aud"]},
308
+ )
309
+ if nonce is not None and not secrets.compare_digest(
310
+ str(claims.get("nonce", "")), nonce
311
+ ):
312
+ raise ConfigurationError("ID token nonce mismatch")
313
+ if not isinstance(claims["sub"], str) or not claims["sub"]:
314
+ raise ConfigurationError("ID token subject missing")
315
+ return claims
316
+ except (jwt.PyJWTError, KeyError, TypeError, ValueError) as exc:
317
+ raise ConfigurationError("ID token validation failed") from exc
318
+
319
+ async def refresh(
320
+ self, credential: OAuthCredential, cancel: CancelToken | None = None
321
+ ) -> OAuthCredential:
322
+ if credential.provider != self.provider:
323
+ raise ConfigurationError("Credential belongs to another provider")
324
+ cancel = cancel or CancelToken()
325
+ body = {
326
+ "grant_type": "refresh_token",
327
+ "client_id": credential.client_id or _SETTINGS[self.provider][2],
328
+ "refresh_token": credential.refresh_token,
329
+ }
330
+ if self.provider == "openai-chatgpt":
331
+ body["resource"] = _RESOURCE
332
+ data = await self._token(body, cancel)
333
+ result = self._credential(data, body["client_id"])
334
+ id_token = credential.id_token
335
+ if self.provider == "openai-chatgpt" and data.get("id_token"):
336
+ claims = await self._verify_id_token(data["id_token"], body["client_id"], None, cancel)
337
+ if claims["sub"] != credential.subject:
338
+ raise ConfigurationError("Refresh returned another account")
339
+ id_token = data["id_token"]
340
+ return replace(
341
+ result, subject=credential.subject, id_token=id_token, host_id=credential.host_id
342
+ )
343
+
344
+ async def login(
345
+ self,
346
+ on_auth_url: Callable[[str], Any],
347
+ *,
348
+ on_prompt: Callable[[str], Any] | None = None,
349
+ cancel: CancelToken | None = None,
350
+ timeout: float = 300,
351
+ **begin_options: Any,
352
+ ) -> OAuthCredential:
353
+ """Listen on loopback before notifying the UI. UI opens the URL explicitly.
354
+
355
+ Pass on_prompt(url)->full_callback_url for manual flow / busy-port fallback.
356
+ For Anthropic copy_code, return code#state. Nothing is printed or persisted.
357
+ """
358
+ cancel = cancel or CancelToken()
359
+ attempt = self.begin(**begin_options)
360
+ uri = urlsplit(attempt.redirect_uri)
361
+ if begin_options.get("method") == "copy_code":
362
+ if on_prompt is None:
363
+ raise ConfigurationError("copy_code requires on_prompt")
364
+ await invoke(on_auth_url, attempt.authorize_url)
365
+ async with asyncio.timeout(timeout):
366
+ callback = await cancellable(invoke(on_prompt, attempt.redirect_uri), cancel)
367
+ return await self.exchange(attempt, callback, cancel)
368
+ future = asyncio.get_running_loop().create_future()
369
+ handlers = set()
370
+
371
+ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
372
+ task = asyncio.current_task()
373
+ assert task is not None
374
+ handlers.add(task)
375
+ try:
376
+ async with asyncio.timeout(5):
377
+ raw = await reader.readuntil(b"\r\n\r\n")
378
+ line = raw.split(b"\r\n", 1)[0].decode("ascii")
379
+ method, target, _ = line.split(" ", 2)
380
+ if method != "GET" or not target.startswith("/") or target.startswith("//"):
381
+ raise ValueError
382
+ callback = f"{uri.scheme}://{uri.netloc}{target}"
383
+ self._callback(attempt, callback)
384
+ if not future.done():
385
+ future.set_result(callback)
386
+ response = b"Login received. You may close this window."
387
+ writer.write(
388
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\nContent-Length: "
389
+ + str(len(response)).encode()
390
+ + b"\r\n\r\n"
391
+ + response
392
+ )
393
+ await writer.drain()
394
+ except (
395
+ ValueError,
396
+ ConfigurationError,
397
+ TimeoutError,
398
+ asyncio.IncompleteReadError,
399
+ asyncio.LimitOverrunError,
400
+ ):
401
+ if attempt.consumed and not future.done():
402
+ future.set_exception(
403
+ ConfigurationError("OAuth authorization was declined or failed")
404
+ )
405
+ writer.write(
406
+ b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
407
+ )
408
+ finally:
409
+ writer.close()
410
+ await writer.wait_closed()
411
+ handlers.discard(task)
412
+
413
+ server = None
414
+ try:
415
+ try:
416
+ server = await asyncio.start_server(handle, "127.0.0.1", uri.port, limit=16384)
417
+ except OSError:
418
+ if on_prompt is None:
419
+ raise ConfigurationError(
420
+ "OAuth loopback port unavailable; pass on_prompt for manual callback"
421
+ ) from None
422
+ await invoke(on_auth_url, attempt.authorize_url)
423
+ async with asyncio.timeout(timeout):
424
+ callback = await cancellable(
425
+ future if server else invoke(on_prompt, attempt.redirect_uri), cancel
426
+ )
427
+ return await self.exchange(attempt, callback, cancel)
428
+ finally:
429
+ if server:
430
+ server.close()
431
+ await server.wait_closed()
432
+ for task in list(handlers):
433
+ task.cancel()
434
+ await asyncio.gather(*handlers, return_exceptions=True)
435
+ if not future.done():
436
+ future.cancel()
437
+
438
+ async def device_login(
439
+ self,
440
+ on_device_code: Callable[[dict[str, Any]], Any],
441
+ *,
442
+ cancel: CancelToken | None = None,
443
+ timeout: float = 900,
444
+ ) -> OAuthCredential:
445
+ if self.provider != "openai-codex":
446
+ raise ConfigurationError("Device login is available for Codex only")
447
+ cancel = cancel or CancelToken()
448
+ base = "https://auth.openai.com/api/accounts/deviceauth/"
449
+ device = await self.transport.request_json(
450
+ base + "usercode", {"client_id": _SETTINGS[self.provider][2]}, {}, cancel
451
+ )
452
+ interval = max(float(device.get("interval", 5)), 1)
453
+ await invoke(
454
+ on_device_code,
455
+ {
456
+ "verification_uri": "https://auth.openai.com/codex/device",
457
+ "user_code": device["user_code"],
458
+ "interval": interval,
459
+ "expires_in": timeout,
460
+ },
461
+ )
462
+ async with asyncio.timeout(timeout):
463
+ while True:
464
+ await cancellable(asyncio.sleep(interval), cancel)
465
+ try:
466
+ data = await self.transport.request_json(
467
+ base + "token",
468
+ {
469
+ "device_auth_id": device["device_auth_id"],
470
+ "user_code": device["user_code"],
471
+ },
472
+ {},
473
+ cancel,
474
+ )
475
+ except ProviderHTTPError as exc:
476
+ if exc.status in {403, 404}:
477
+ continue
478
+ raise
479
+ if data.get("error") in {
480
+ "authorization_pending",
481
+ "deviceauth_authorization_pending",
482
+ }:
483
+ continue
484
+ if data.get("error") == "slow_down":
485
+ interval += 5
486
+ continue
487
+ if not data.get("authorization_code") or not data.get("code_verifier"):
488
+ raise ProviderProtocolError("Invalid device authorization response")
489
+ token = await self._token(
490
+ {
491
+ "grant_type": "authorization_code",
492
+ "client_id": _SETTINGS[self.provider][2],
493
+ "code": data["authorization_code"],
494
+ "code_verifier": data["code_verifier"],
495
+ "redirect_uri": "https://auth.openai.com/deviceauth/callback",
496
+ },
497
+ cancel,
498
+ )
499
+ return self._credential(token, _SETTINGS[self.provider][2])
500
+
501
+
502
+ class RefreshingCredentials:
503
+ """Serialize refresh-token rotation; persist receives an immutable new record."""
504
+
505
+ def __init__(
506
+ self,
507
+ credential: OAuthCredential,
508
+ *,
509
+ client: OAuthClient | None = None,
510
+ persist: Callable[[OAuthCredential], Any] | None = None,
511
+ margin: float = 300,
512
+ ) -> None:
513
+ self.credential = credential
514
+ self.client = client or OAuthClient(credential.provider)
515
+ self.persist = persist
516
+ self.margin = margin
517
+ self._lock = asyncio.Lock()
518
+ self._dirty = False
519
+
520
+ async def get(self, cancel: CancelToken | None = None) -> OAuthCredential:
521
+ cancel = cancel or CancelToken()
522
+ acquisition = asyncio.create_task(self._lock.acquire())
523
+ try:
524
+ await cancellable(acquisition, cancel)
525
+ cancel.raise_if_cancelled()
526
+ if self._dirty:
527
+ await invoke(self.persist, self.credential)
528
+ self._dirty = False
529
+ if self.credential.expires_at <= time.time() + self.margin:
530
+ self.credential = await self.client.refresh(self.credential, cancel)
531
+ self._dirty = True
532
+ await invoke(self.persist, self.credential)
533
+ self._dirty = False
534
+ return self.credential
535
+ finally:
536
+ if (
537
+ acquisition.done()
538
+ and not acquisition.cancelled()
539
+ and acquisition.exception() is None
540
+ and acquisition.result()
541
+ ):
542
+ self._lock.release()