chatgpt-plan-auth 1.0.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,77 @@
1
+ """Sign in with ChatGPT (ChatGPT plan usage) for locally hosted apps.
2
+
3
+ Implements the OAuth flow documented at
4
+ https://developers.openai.com/siwc/token-sharing-open-source/: register a
5
+ dynamic client, authorize with PKCE, exchange the code for tokens, refresh and
6
+ rotate them, read the plan's model catalog, and shape Responses API requests
7
+ for the plan usage route.
8
+
9
+ The protocol code needs only :mod:`httpx`. The OpenAI SDK wrapper
10
+ (:mod:`chatgpt_plan_auth.client`) needs the ``openai`` extra.
11
+ """
12
+
13
+ from .auth import (
14
+ AuthorizationRequest,
15
+ ChatGPTCredentials,
16
+ ChatGPTAuth,
17
+ async_exchange_code,
18
+ async_token_request,
19
+ create_authorization_request,
20
+ create_ext_agent_host_id,
21
+ credentials_from_token,
22
+ parse_callback_url,
23
+ validate_id_token,
24
+ )
25
+ from .catalog import (
26
+ async_get_subscription_models,
27
+ async_get_subscription_models_for_credentials,
28
+ subscription_model_options,
29
+ )
30
+ from .const import (
31
+ AUTH_METHOD_CHATGPT,
32
+ AUTHORIZE_URL,
33
+ CLIENT_ID_PREFIX,
34
+ DYNAMIC_AGENT_CLIENT,
35
+ MODELS_URL,
36
+ PLAN_SCOPE,
37
+ REDIRECT_URI,
38
+ RESOURCE,
39
+ SCOPE,
40
+ SUBSCRIPTION_MODEL_SUGGESTIONS,
41
+ TOKEN_URL,
42
+ )
43
+ from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
44
+ from .requests import shape_request_for_subscription, subscription_error_message
45
+
46
+ __version__ = "1.0.0"
47
+
48
+ __all__ = [
49
+ "AUTH_METHOD_CHATGPT",
50
+ "AUTHORIZE_URL",
51
+ "CLIENT_ID_PREFIX",
52
+ "DYNAMIC_AGENT_CLIENT",
53
+ "MODELS_URL",
54
+ "PLAN_SCOPE",
55
+ "REDIRECT_URI",
56
+ "RESOURCE",
57
+ "SCOPE",
58
+ "SUBSCRIPTION_MODEL_SUGGESTIONS",
59
+ "TOKEN_URL",
60
+ "AuthorizationRequest",
61
+ "ChatGPTCredentials",
62
+ "ChatGPTAuth",
63
+ "ChatGPTPlanAuthError",
64
+ "ChatGPTPlanAuthFatalError",
65
+ "async_exchange_code",
66
+ "async_get_subscription_models",
67
+ "async_get_subscription_models_for_credentials",
68
+ "async_token_request",
69
+ "create_authorization_request",
70
+ "create_ext_agent_host_id",
71
+ "credentials_from_token",
72
+ "parse_callback_url",
73
+ "shape_request_for_subscription",
74
+ "subscription_error_message",
75
+ "subscription_model_options",
76
+ "validate_id_token",
77
+ ]
@@ -0,0 +1,560 @@
1
+ """Sign in with ChatGPT (ChatGPT plan usage) authorization.
2
+
3
+ This implements the OAuth flow documented at
4
+ https://developers.openai.com/siwc/token-sharing-open-source/ which lets an
5
+ open-source, locally hosted app use an eligible ChatGPT plan for eligible
6
+ Responses API requests. The access token obtained here is used as the bearer
7
+ token against ``https://api.openai.com/v1`` instead of an API key.
8
+
9
+ HISTORY AND SCOPE
10
+ -----------------
11
+ This module was extracted from Home Assistant core, where the same code made
12
+ the reviewer's point for them: it is deep domain knowledge about an external
13
+ system and belongs in a library, not in core. Six modules are ported verbatim
14
+ apart from ``hass`` becoming a plain :class:`httpx.AsyncClient`:
15
+
16
+ ``const`` / ``auth`` / ``catalog`` / ``requests`` (this package, ``httpx`` only)
17
+ and ``client`` (the OpenAI SDK wrapper, behind the ``openai`` extra).
18
+
19
+ DELIBERATE SIMPLIFICATION: NO ID TOKEN SIGNATURE VERIFICATION
20
+ -------------------------------------------------------------
21
+ ``/.well-known/jwks.json`` is never fetched, RSA keys are not parsed and no
22
+ crypto dependency is carried. The ID token is received directly from the
23
+ provider's TLS-authenticated token endpoint in the same response as the code
24
+ exchange, which is the trust model Home Assistant's own OAuth2 flow uses, so
25
+ the signature adds nothing an attacker who already controls the token response
26
+ could not forge. Issuer, audience, expiry and the per-attempt nonce are still
27
+ verified in :func:`validate_id_token`, and the callback ``state`` is checked in
28
+ :func:`async_exchange_code`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import base64
35
+ import binascii
36
+ from collections.abc import Callable, Mapping
37
+ from dataclasses import dataclass
38
+ from datetime import datetime, timedelta, timezone
39
+ import hashlib
40
+ import json
41
+ import logging
42
+ import secrets
43
+ from typing import Any
44
+ import uuid
45
+ from urllib.parse import parse_qsl, urlencode, urlparse
46
+
47
+ import httpx
48
+
49
+ from .const import (
50
+ AGENT_NAME_HINT,
51
+ AUTH_METHOD_CHATGPT,
52
+ AUTHORIZE_URL,
53
+ CONF_ACCESS_TOKEN,
54
+ CONF_AUTH_METHOD,
55
+ CONF_CLIENT_ID,
56
+ CONF_EARLIEST_REFRESH_AT,
57
+ CONF_EMAIL,
58
+ CONF_EXPIRES_AT,
59
+ CONF_EXT_AGENT_HOST_ID,
60
+ CONF_ID_TOKEN,
61
+ CONF_REFRESH_TOKEN,
62
+ CONF_SCOPES,
63
+ CONF_SUB,
64
+ DYNAMIC_AGENT_CLIENT,
65
+ EXT_AGENT_HOST_ID_SCHEME,
66
+ FATAL_REFRESH_ERRORS,
67
+ HTTP_TIMEOUT,
68
+ ISSUER,
69
+ REDIRECT_URI,
70
+ RESOURCE,
71
+ SCOPE,
72
+ TOKEN_EXPIRY_MARGIN,
73
+ TOKEN_URL,
74
+ )
75
+ from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
76
+
77
+ _LOGGER = logging.getLogger(__name__)
78
+
79
+
80
+ def _utcnow() -> datetime:
81
+ """Return the current UTC time."""
82
+ return datetime.now(timezone.utc)
83
+
84
+
85
+ def _parse_optional_datetime(value: Any) -> datetime | None:
86
+ """Parse an optional ISO 8601 timestamp."""
87
+ if not value or not isinstance(value, str):
88
+ return None
89
+ try:
90
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
91
+ except ValueError:
92
+ return None
93
+ return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
94
+
95
+
96
+ @dataclass
97
+ class ChatGPTCredentials:
98
+ """Credentials of one Sign in with ChatGPT registration."""
99
+
100
+ client_id: str
101
+ ext_agent_host_id: str
102
+ sub: str
103
+ email: str | None
104
+ access_token: str
105
+ refresh_token: str
106
+ id_token: str
107
+ expires_at: datetime
108
+ scopes: list[str]
109
+ earliest_refresh_at: datetime | None = None
110
+
111
+ @property
112
+ def unique_id(self) -> str:
113
+ """Return the account identity scoped to this registration.
114
+
115
+ The subject identifies the ChatGPT account, the issued client id the
116
+ registration (one account can have several workspaces registered).
117
+ """
118
+ return f"{self.sub}:{self.client_id}"
119
+
120
+ def as_entry_data(self) -> dict[str, Any]:
121
+ """Return the credentials as a plain mapping, ready to be persisted."""
122
+ data: dict[str, Any] = {
123
+ CONF_AUTH_METHOD: AUTH_METHOD_CHATGPT,
124
+ CONF_CLIENT_ID: self.client_id,
125
+ CONF_EXT_AGENT_HOST_ID: self.ext_agent_host_id,
126
+ CONF_SUB: self.sub,
127
+ CONF_ACCESS_TOKEN: self.access_token,
128
+ CONF_REFRESH_TOKEN: self.refresh_token,
129
+ CONF_ID_TOKEN: self.id_token,
130
+ CONF_EXPIRES_AT: self.expires_at.isoformat(),
131
+ CONF_SCOPES: self.scopes,
132
+ }
133
+ if self.email is not None:
134
+ data[CONF_EMAIL] = self.email
135
+ if self.earliest_refresh_at is not None:
136
+ data[CONF_EARLIEST_REFRESH_AT] = self.earliest_refresh_at.isoformat()
137
+ return data
138
+
139
+ @classmethod
140
+ def from_entry_data(cls, data: Mapping[str, Any]) -> ChatGPTCredentials:
141
+ """Build credentials from persisted data."""
142
+ return cls(
143
+ client_id=data[CONF_CLIENT_ID],
144
+ ext_agent_host_id=data.get(CONF_EXT_AGENT_HOST_ID, ""),
145
+ sub=data.get(CONF_SUB, ""),
146
+ email=data.get(CONF_EMAIL),
147
+ access_token=data[CONF_ACCESS_TOKEN],
148
+ refresh_token=data[CONF_REFRESH_TOKEN],
149
+ id_token=data.get(CONF_ID_TOKEN, ""),
150
+ expires_at=_parse_optional_datetime(data[CONF_EXPIRES_AT]) or _utcnow(),
151
+ scopes=list(data.get(CONF_SCOPES, [])),
152
+ earliest_refresh_at=_parse_optional_datetime(
153
+ data.get(CONF_EARLIEST_REFRESH_AT, "")
154
+ ),
155
+ )
156
+
157
+
158
+ def create_ext_agent_host_id() -> str:
159
+ """Return a new stable ``ext_agent_host_id`` for this installation.
160
+
161
+ The host id identifies the host the app runs on, not the signed in account,
162
+ and every registration of one instance shares it. A UUID based host id is
163
+ documented as a supported alternative to a JWK thumbprint URI and needs no
164
+ key management; callers generate it once and store it themselves.
165
+ """
166
+ return f"{EXT_AGENT_HOST_ID_SCHEME}{uuid.uuid4()}"
167
+
168
+
169
+ @dataclass
170
+ class AuthorizationRequest:
171
+ """A pending SIWC authorization attempt."""
172
+
173
+ url: str
174
+ state: str
175
+ nonce: str
176
+ code_verifier: str
177
+ redirect_uri: str
178
+ ext_agent_host_id: str
179
+ # The client id to exchange the code with: `dynamic_agent_client` until the
180
+ # callback returns the issued client id.
181
+ client_id: str
182
+ registration: bool
183
+
184
+
185
+ def _create_pkce_pair() -> tuple[str, str]:
186
+ """Create a PKCE verifier and its S256 challenge."""
187
+ verifier = secrets.token_urlsafe(64)
188
+ digest = hashlib.sha256(verifier.encode()).digest()
189
+ challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
190
+ return verifier, challenge
191
+
192
+
193
+ def create_authorization_request(
194
+ *,
195
+ client_id: str | None,
196
+ ext_agent_host_id: str,
197
+ id_token_hint: str | None = None,
198
+ agent_name_hint: str = AGENT_NAME_HINT,
199
+ ) -> AuthorizationRequest:
200
+ """Build the authorization URL for a new or a returning ChatGPT account.
201
+
202
+ This is the first half of the flow; :func:`async_exchange_code` finishes it.
203
+ A fresh state, nonce and PKCE verifier are generated for every attempt.
204
+ """
205
+ state = secrets.token_urlsafe(32)
206
+ nonce = secrets.token_urlsafe(32)
207
+ verifier, challenge = _create_pkce_pair()
208
+
209
+ registration = client_id is None
210
+ params = {
211
+ "response_type": "code",
212
+ "client_id": DYNAMIC_AGENT_CLIENT if registration else client_id,
213
+ "redirect_uri": REDIRECT_URI,
214
+ "scope": SCOPE,
215
+ "resource": RESOURCE,
216
+ "state": state,
217
+ "nonce": nonce,
218
+ "code_challenge": challenge,
219
+ "code_challenge_method": "S256",
220
+ "ext_agent_host_id": ext_agent_host_id,
221
+ }
222
+ if registration:
223
+ # agent_name_hint is display metadata and is only sent for the first
224
+ # (dynamic client registration) authorization request.
225
+ params["agent_name_hint"] = agent_name_hint
226
+ if id_token_hint:
227
+ params["id_token_hint"] = id_token_hint
228
+
229
+ return AuthorizationRequest(
230
+ url=f"{AUTHORIZE_URL}?{urlencode(params)}",
231
+ state=state,
232
+ nonce=nonce,
233
+ code_verifier=verifier,
234
+ redirect_uri=REDIRECT_URI,
235
+ ext_agent_host_id=ext_agent_host_id,
236
+ client_id=client_id or DYNAMIC_AGENT_CLIENT,
237
+ registration=registration,
238
+ )
239
+
240
+
241
+ def parse_callback_url(url: str) -> dict[str, str]:
242
+ """Parse the query of the URL the user's browser ended up on.
243
+
244
+ The loopback callback is normally on a different machine than the app, so
245
+ the page itself cannot be loaded. The address bar still holds the OAuth
246
+ parameters, which is all that is needed.
247
+ """
248
+ return dict(parse_qsl(urlparse(url.strip()).query))
249
+
250
+
251
+ def _error_code(body: Any) -> str | None:
252
+ """Return the OAuth error code of a token endpoint response body."""
253
+ match body:
254
+ case {"error": {"code": str() as code}}:
255
+ return code
256
+ case {"error": str() as code}:
257
+ return code
258
+ case {"code": str() as code}:
259
+ return code
260
+ return None
261
+
262
+
263
+ def _error_message(body: Any) -> str:
264
+ """Return the message of a token endpoint error response body."""
265
+ match body:
266
+ case {"error": {"message": str() as message}}:
267
+ return message
268
+ case {"error_description": str() as description}:
269
+ return description
270
+ case {"error": {"error_description": str() as description}}:
271
+ return description
272
+ return "unknown error"
273
+
274
+
275
+ async def async_token_request(
276
+ client: httpx.AsyncClient,
277
+ data: dict[str, str],
278
+ *,
279
+ fatal_errors: bool = False,
280
+ ) -> dict[str, Any]:
281
+ """POST a form encoded grant to the SIWC token endpoint.
282
+
283
+ ``fatal_errors`` maps the errors which invalidate the stored refresh token
284
+ to :class:`ChatGPTPlanAuthFatalError` so the caller can start reauth.
285
+ """
286
+ try:
287
+ response = await client.post(TOKEN_URL, data=data, timeout=HTTP_TIMEOUT)
288
+ except httpx.HTTPError as err:
289
+ raise ChatGPTPlanAuthError(f"Error talking to OpenAI: {err}") from err
290
+
291
+ if response.status_code == 200:
292
+ try:
293
+ return dict(response.json())
294
+ except ValueError as err:
295
+ raise ChatGPTPlanAuthError("Invalid token response from OpenAI") from err
296
+
297
+ try:
298
+ body = response.json()
299
+ except ValueError:
300
+ body = None
301
+ code = _error_code(body)
302
+ message = _error_message(body)
303
+
304
+ if fatal_errors and code in FATAL_REFRESH_ERRORS:
305
+ raise ChatGPTPlanAuthFatalError(
306
+ f"Sign in with ChatGPT authentication failed ({code}): {message}"
307
+ )
308
+
309
+ raise ChatGPTPlanAuthError(f"Sign in with ChatGPT failed ({code}): {message}")
310
+
311
+
312
+ def _decode_jwt_claims(id_token: str) -> Mapping[str, Any]:
313
+ """Return the claims of an ID token without verifying its signature.
314
+
315
+ See the module docstring: the token arrives from the TLS-authenticated
316
+ token endpoint, so the signature is not checked and no key material or
317
+ crypto dependency is carried.
318
+ """
319
+ parts = id_token.split(".")
320
+ if len(parts) != 3:
321
+ raise ChatGPTPlanAuthError("Invalid ID token: not a JWT")
322
+ padding = "=" * (-len(parts[1]) % 4)
323
+ try:
324
+ claims = json.loads(base64.urlsafe_b64decode(parts[1] + padding))
325
+ except (binascii.Error, ValueError, UnicodeDecodeError) as err:
326
+ raise ChatGPTPlanAuthError(f"Invalid ID token: {err}") from err
327
+ if not isinstance(claims, Mapping):
328
+ raise ChatGPTPlanAuthError("Invalid ID token: claims are not an object")
329
+ return claims
330
+
331
+
332
+ def validate_id_token(
333
+ id_token: str, *, client_id: str, nonce: str
334
+ ) -> Mapping[str, Any]:
335
+ """Validate an ID token and return its claims.
336
+
337
+ The issuer, the audience (the issued client id), the expiry and the nonce
338
+ of this authorization attempt are all verified. The signature is not; see
339
+ the module docstring.
340
+ """
341
+ claims = _decode_jwt_claims(id_token)
342
+
343
+ if claims.get("iss") != ISSUER:
344
+ raise ChatGPTPlanAuthError(
345
+ f"Invalid ID token: unexpected issuer {claims.get('iss')!r}"
346
+ )
347
+
348
+ audience = claims.get("aud")
349
+ audiences = [audience] if isinstance(audience, str) else list(audience or [])
350
+ if client_id not in audiences:
351
+ raise ChatGPTPlanAuthError("Invalid ID token: audience mismatch")
352
+
353
+ expires_at = claims.get("exp")
354
+ if not isinstance(expires_at, (int, float)) or expires_at <= _utcnow().timestamp():
355
+ raise ChatGPTPlanAuthError("Invalid ID token: expired")
356
+
357
+ if claims.get("nonce") != nonce:
358
+ raise ChatGPTPlanAuthError("Invalid ID token: nonce mismatch")
359
+
360
+ return claims
361
+
362
+
363
+ def _parse_optional_string(value: Any) -> str | None:
364
+ """Return a string claim value, or None."""
365
+ return value if isinstance(value, str) else None
366
+
367
+
368
+ async def async_exchange_code(
369
+ client: httpx.AsyncClient,
370
+ code: str,
371
+ *,
372
+ request: AuthorizationRequest,
373
+ callback_client_id: str | None = None,
374
+ callback_state: str | None = None,
375
+ ) -> ChatGPTCredentials:
376
+ """Exchange the authorization code of a callback for credentials.
377
+
378
+ ``callback_state`` is compared against the state of ``request`` (the CSRF
379
+ check) and the nonce of the ID token is verified by
380
+ :func:`validate_id_token`. A dynamic client registration returns the issued
381
+ client id in the callback as ``client_id``; pass it as
382
+ ``callback_client_id`` so the code is exchanged with the registered client.
383
+ """
384
+ if callback_state is not None and callback_state != request.state:
385
+ raise ChatGPTPlanAuthError("Sign in with ChatGPT failed: state mismatch")
386
+
387
+ client_id = callback_client_id or request.client_id
388
+
389
+ token = await async_token_request(
390
+ client,
391
+ {
392
+ "grant_type": "authorization_code",
393
+ "client_id": client_id,
394
+ "code": code,
395
+ "code_verifier": request.code_verifier,
396
+ "redirect_uri": request.redirect_uri,
397
+ "resource": RESOURCE,
398
+ },
399
+ fatal_errors=True,
400
+ )
401
+
402
+ sub: str | None = None
403
+ email: str | None = None
404
+ if id_token := token.get("id_token"):
405
+ claims = validate_id_token(id_token, client_id=client_id, nonce=request.nonce)
406
+ sub = _parse_optional_string(claims.get("sub"))
407
+ email = _parse_optional_string(claims.get("email"))
408
+
409
+ return credentials_from_token(
410
+ token,
411
+ client_id=client_id,
412
+ ext_agent_host_id=request.ext_agent_host_id,
413
+ sub=sub,
414
+ email=email,
415
+ )
416
+
417
+
418
+ def credentials_from_token(
419
+ token: Mapping[str, Any],
420
+ *,
421
+ client_id: str,
422
+ ext_agent_host_id: str,
423
+ previous: ChatGPTCredentials | None = None,
424
+ sub: str | None = None,
425
+ email: str | None = None,
426
+ ) -> ChatGPTCredentials:
427
+ """Build credentials from a token endpoint response.
428
+
429
+ Refresh tokens rotate on every refresh, so the response always replaces the
430
+ stored credentials. Fields the response does not repeat are kept from
431
+ ``previous``.
432
+ """
433
+ scopes = str(token.get("scope", "")).split()
434
+ expires_in = token.get("expires_in")
435
+ return ChatGPTCredentials(
436
+ client_id=client_id,
437
+ ext_agent_host_id=ext_agent_host_id,
438
+ sub=sub if sub is not None else (previous.sub if previous else ""),
439
+ email=email if email is not None else (previous.email if previous else None),
440
+ access_token=token["access_token"],
441
+ refresh_token=token.get("refresh_token")
442
+ or (previous.refresh_token if previous else ""),
443
+ id_token=token.get("id_token") or (previous.id_token if previous else ""),
444
+ expires_at=_utcnow() + timedelta(seconds=int(expires_in) if expires_in else 3600),
445
+ scopes=scopes or (previous.scopes if previous else []),
446
+ earliest_refresh_at=_parse_optional_datetime(token.get("earliest_refresh_at")),
447
+ )
448
+
449
+
450
+ class ChatGPTAuth:
451
+ """Manage the SIWC token lifecycle of one set of credentials.
452
+
453
+ The manager is storage agnostic: it refreshes through the given
454
+ :class:`httpx.AsyncClient` and reports every rotation through ``on_update``
455
+ so the caller can persist it. A dead refresh token raises
456
+ :class:`ChatGPTPlanAuthFatalError`; starting the reauth flow is the
457
+ caller's job.
458
+ """
459
+
460
+ def __init__(
461
+ self,
462
+ credentials: ChatGPTCredentials,
463
+ *,
464
+ client: httpx.AsyncClient,
465
+ on_update: Callable[[ChatGPTCredentials], None] | None = None,
466
+ ) -> None:
467
+ """Initialize the token manager."""
468
+ self.credentials = credentials
469
+ self.client = client
470
+ self.on_update = on_update
471
+ self._lock = asyncio.Lock()
472
+
473
+ @classmethod
474
+ def from_entry_data(
475
+ cls,
476
+ data: Mapping[str, Any],
477
+ *,
478
+ client: httpx.AsyncClient,
479
+ on_update: Callable[[ChatGPTCredentials], None] | None = None,
480
+ ) -> ChatGPTAuth:
481
+ """Build the token manager from persisted credentials."""
482
+ return cls(
483
+ ChatGPTCredentials.from_entry_data(data), client=client, on_update=on_update
484
+ )
485
+
486
+ async def async_get_access_token(self) -> str:
487
+ """Return a valid access token, refreshing it when it is about to expire.
488
+
489
+ This is used as the async `api_key` provider of the OpenAI client, so
490
+ the token is checked (and refreshed) before every request.
491
+ """
492
+ async with self._lock:
493
+ if self._needs_refresh():
494
+ await self._async_refresh()
495
+ return self.credentials.access_token
496
+
497
+ def _needs_refresh(self) -> bool:
498
+ """Return if the access token should be refreshed."""
499
+ now = _utcnow()
500
+ if (
501
+ self.credentials.earliest_refresh_at is not None
502
+ and now < self.credentials.earliest_refresh_at
503
+ ):
504
+ # The token endpoint rejects refresh attempts before this time.
505
+ return False
506
+ return now >= self.credentials.expires_at - TOKEN_EXPIRY_MARGIN
507
+
508
+ async def async_handle_authentication_error(self) -> None:
509
+ """Refresh the token after the API rejected the access token."""
510
+ async with self._lock:
511
+ if (
512
+ self.credentials.earliest_refresh_at is not None
513
+ and _utcnow() < self.credentials.earliest_refresh_at
514
+ ):
515
+ # The token endpoint refuses refreshes before this time, so the
516
+ # rejected request was not a token expiry problem.
517
+ return
518
+ await self._async_refresh()
519
+
520
+ async def _async_refresh(self) -> None:
521
+ """Exchange the refresh token for a new set of tokens."""
522
+ token = await async_token_request(
523
+ self.client,
524
+ {
525
+ "grant_type": "refresh_token",
526
+ "client_id": self.credentials.client_id,
527
+ "refresh_token": self.credentials.refresh_token,
528
+ "resource": RESOURCE,
529
+ },
530
+ fatal_errors=True,
531
+ )
532
+
533
+ self._store(
534
+ credentials_from_token(
535
+ token,
536
+ client_id=self.credentials.client_id,
537
+ ext_agent_host_id=self.credentials.ext_agent_host_id,
538
+ previous=self.credentials,
539
+ )
540
+ )
541
+
542
+ def _store(self, credentials: ChatGPTCredentials) -> None:
543
+ """Replace the (rotated) credentials and hand them to the caller."""
544
+ self.credentials = credentials
545
+ if self.on_update is not None:
546
+ self.on_update(credentials)
547
+
548
+
549
+ __all__ = [
550
+ "AuthorizationRequest",
551
+ "ChatGPTCredentials",
552
+ "ChatGPTAuth",
553
+ "async_exchange_code",
554
+ "async_token_request",
555
+ "create_authorization_request",
556
+ "create_ext_agent_host_id",
557
+ "credentials_from_token",
558
+ "parse_callback_url",
559
+ "validate_id_token",
560
+ ]
@@ -0,0 +1,94 @@
1
+ """Fetch the ChatGPT plan model catalog.
2
+
3
+ The plan usage route serves a non-standard catalog body (a ``models`` list of
4
+ objects carrying a ``slug``) which the OpenAI SDK cannot parse, so the request
5
+ is made directly with :mod:`httpx`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import httpx
11
+
12
+ from .auth import ChatGPTCredentials, ChatGPTAuth
13
+ from .const import (
14
+ HTTP_TIMEOUT,
15
+ MODELS_URL,
16
+ SUBSCRIPTION_MODEL_SUGGESTIONS,
17
+ )
18
+ from .errors import ChatGPTPlanAuthError, ChatGPTPlanAuthFatalError
19
+
20
+
21
+ def subscription_model_options(models: list[str]) -> list[str]:
22
+ """Return the models to offer, falling back to the known suggestions."""
23
+ return models or SUBSCRIPTION_MODEL_SUGGESTIONS
24
+
25
+
26
+ async def async_get_subscription_models(
27
+ client: httpx.AsyncClient, auth: ChatGPTAuth
28
+ ) -> list[str]:
29
+ """Return the model slugs offered to a ChatGPT plan account."""
30
+ return await _async_fetch_subscription_models(
31
+ client, await auth.async_get_access_token()
32
+ )
33
+
34
+
35
+ async def async_get_subscription_models_for_credentials(
36
+ client: httpx.AsyncClient, credentials: ChatGPTCredentials
37
+ ) -> list[str]:
38
+ """Return the model slugs of an account which is not stored yet.
39
+
40
+ Used while creating or converting an entry, when there is no
41
+ :class:`ChatGPTAuth` (and therefore no stored token) to supply the access
42
+ token.
43
+ """
44
+ return await _async_fetch_subscription_models(client, credentials.access_token)
45
+
46
+
47
+ async def _async_fetch_subscription_models(
48
+ client: httpx.AsyncClient, access_token: str
49
+ ) -> list[str]:
50
+ """Return the model slugs offered to a ChatGPT plan account."""
51
+ try:
52
+ response = await client.get(
53
+ MODELS_URL,
54
+ headers={"Authorization": f"Bearer {access_token}"},
55
+ timeout=HTTP_TIMEOUT,
56
+ )
57
+ except httpx.HTTPError as err:
58
+ raise ChatGPTPlanAuthError(f"Error talking to OpenAI: {err}") from err
59
+
60
+ if response.status_code in (401, 403):
61
+ raise ChatGPTPlanAuthFatalError(
62
+ "Sign in with ChatGPT authentication failed while listing models"
63
+ )
64
+
65
+ try:
66
+ response.raise_for_status()
67
+ except httpx.HTTPError as err:
68
+ raise ChatGPTPlanAuthError(f"Error listing OpenAI models: {err}") from err
69
+
70
+ try:
71
+ payload = response.json()
72
+ except ValueError as err:
73
+ raise ChatGPTPlanAuthError(
74
+ "Invalid model catalog response from OpenAI"
75
+ ) from err
76
+
77
+ models = payload.get("models") if isinstance(payload, dict) else None
78
+ if not isinstance(models, list):
79
+ raise ChatGPTPlanAuthError(
80
+ "The model catalog response from OpenAI did not contain a models list"
81
+ )
82
+
83
+ return [
84
+ item["slug"]
85
+ for item in models
86
+ if isinstance(item, dict) and isinstance(item.get("slug"), str)
87
+ ]
88
+
89
+
90
+ __all__ = [
91
+ "async_get_subscription_models",
92
+ "async_get_subscription_models_for_credentials",
93
+ "subscription_model_options",
94
+ ]
@@ -0,0 +1,84 @@
1
+ """OpenAI SDK client which authenticates with a ChatGPT plan.
2
+
3
+ Requires the ``openai`` extra: ``pip install chatgpt-plan-auth[openai]``. Nothing
4
+ else in this package imports the OpenAI SDK.
5
+
6
+ The ported overrides carry no ``typing.override`` decorator: that is Python
7
+ 3.12+ and this package supports 3.10.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import AsyncGenerator
13
+ from typing import Any
14
+
15
+ import httpx
16
+ from openai import AsyncOpenAI
17
+
18
+ from .auth import ChatGPTAuth
19
+ from .const import PLACEHOLDER_API_KEY
20
+
21
+
22
+ class ChatGPTSubscriptionAuth(httpx.Auth):
23
+ """Attach the SIWC access token and refresh it once on a 401."""
24
+
25
+ def __init__(self, auth: ChatGPTAuth) -> None:
26
+ """Initialize the auth flow."""
27
+ self._auth = auth
28
+
29
+ async def async_auth_flow(
30
+ self, request: httpx.Request
31
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
32
+ """Attach the access token, refreshing it when it was rejected."""
33
+ request.headers["Authorization"] = (
34
+ f"Bearer {await self._auth.async_get_access_token()}"
35
+ )
36
+ response = yield request
37
+ if response.status_code == 401:
38
+ await self._auth.async_handle_authentication_error()
39
+ request.headers["Authorization"] = (
40
+ f"Bearer {self._auth.credentials.access_token}"
41
+ )
42
+ yield request
43
+
44
+
45
+ class ChatGPTSubscriptionClient(AsyncOpenAI):
46
+ """OpenAI client authenticating with a ChatGPT plan subscription."""
47
+
48
+ def __init__(
49
+ self,
50
+ auth: ChatGPTAuth,
51
+ http_client: httpx.AsyncClient,
52
+ **options: Any,
53
+ ) -> None:
54
+ """Initialize the client."""
55
+ self.auth = auth
56
+ # A ChatGPT plan has no API key: the bearer token is attached per request
57
+ # by `custom_auth`, which also refreshes it. The SDK still requires a
58
+ # non-empty `api_key` to build a client, and it does not support a
59
+ # callable one (that raises inside the request).
60
+ options.pop("api_key", None)
61
+ # The plan usage route answers a degraded provider edge with a 5xx and a
62
+ # `retry_after` (60 seconds when this was written), which the SDK honors
63
+ # while backing off. The default of two retries therefore leaves an
64
+ # interactive request waiting minutes before the caller sees the error,
65
+ # so bound it to a single retry and let the caller report the status.
66
+ options.setdefault("max_retries", 1)
67
+ super().__init__(api_key=PLACEHOLDER_API_KEY, http_client=http_client, **options)
68
+
69
+ def with_options(self, **options: Any) -> ChatGPTSubscriptionClient:
70
+ """Return a copy of this client which keeps the ChatGPT plan sign in.
71
+
72
+ The base implementation copies a client with `type(self)(**options)`,
73
+ which would not carry the sign in of this instance.
74
+ """
75
+ options.setdefault("http_client", self._client)
76
+ return ChatGPTSubscriptionClient(self.auth, **options)
77
+
78
+ @property
79
+ def custom_auth(self) -> httpx.Auth | None:
80
+ """Return the token refreshing auth flow."""
81
+ return ChatGPTSubscriptionAuth(self.auth)
82
+
83
+
84
+ __all__ = ["ChatGPTSubscriptionAuth", "ChatGPTSubscriptionClient"]
@@ -0,0 +1,167 @@
1
+ """Constants of the Sign in with ChatGPT (ChatGPT plan usage) protocol.
2
+
3
+ Ports the protocol constants out of Home Assistant core's
4
+ ``openai_conversation/chatgpt_auth.py``. Endpoint URLs, the dynamic client
5
+ registration id, scopes, the ``ext_agent_host_id`` scheme, the parameters and
6
+ tools the plan usage route rejects, and the model suggestions live here.
7
+ """
8
+
9
+ from datetime import timedelta
10
+ from typing import Any
11
+
12
+ AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize"
13
+ TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token"
14
+ ISSUER = "https://auth.openai.com"
15
+ RESOURCE = "https://api.openai.com/v1"
16
+ MODELS_URL = "https://api.openai.com/v1/models"
17
+
18
+ # The callback URI must be an HTTP loopback address on 127.0.0.1; only the port
19
+ # may vary. A fixed port keeps the URI stable between authorization attempts.
20
+ REDIRECT_URI = "http://127.0.0.1:1455/auth/callback"
21
+
22
+ # `dynamic_agent_client` is the first-time registration entrypoint only. It must
23
+ # never be stored: the callback returns the issued client id (oaiapp_...) which
24
+ # is used for the code exchange and all later authorizations.
25
+ DYNAMIC_AGENT_CLIENT = "dynamic_agent_client"
26
+ CLIENT_ID_PREFIX = "oaiapp_"
27
+
28
+ # Display metadata of the first (dynamic client registration) authorization
29
+ # request. Callers pass their own product name.
30
+ AGENT_NAME_HINT = "chatgpt-plan-auth"
31
+
32
+ # The `ext_agent_host_id` scheme: a UUID based host id is documented as a
33
+ # supported alternative to a JWK thumbprint URI and needs no key management.
34
+ EXT_AGENT_HOST_ID_SCHEME = "urn:uuid:"
35
+
36
+ SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
37
+ # Granted scope required before the ChatGPT plan may be used.
38
+ PLAN_SCOPE = "chatgpt.tokens.use.direct"
39
+
40
+ # Refresh the access token this long before it expires. Access tokens live one
41
+ # hour and refresh tokens 30 days.
42
+ TOKEN_EXPIRY_MARGIN = timedelta(minutes=5)
43
+ HTTP_TIMEOUT = 30
44
+
45
+ # Keys of the credential mapping (`ChatGPTCredentials.as_entry_data`). They are
46
+ # plain strings so a caller can persist them wherever it wants; Home Assistant
47
+ # stores them as config entry data.
48
+ CONF_AUTH_METHOD = "auth_method"
49
+ CONF_ACCESS_TOKEN = "access_token"
50
+ CONF_CLIENT_ID = "client_id"
51
+ CONF_EMAIL = "email"
52
+ CONF_EARLIEST_REFRESH_AT = "earliest_refresh_at"
53
+ CONF_EXPIRES_AT = "expires_at"
54
+ CONF_EXT_AGENT_HOST_ID = "ext_agent_host_id"
55
+ CONF_ID_TOKEN = "id_token"
56
+ CONF_REFRESH_TOKEN = "refresh_token"
57
+ CONF_SCOPES = "scopes"
58
+ CONF_SUB = "sub"
59
+
60
+ AUTH_METHOD_API_KEY = "api_key"
61
+ AUTH_METHOD_CHATGPT = "chatgpt"
62
+
63
+ # Token endpoint errors which mean the refresh token can never be used again.
64
+ FATAL_REFRESH_ERRORS = frozenset(
65
+ {
66
+ "invalid_grant",
67
+ "invalid_client",
68
+ "invalid_refresh_token",
69
+ "refresh_token_expired",
70
+ "refresh_token_invalidated",
71
+ "refresh_token_reused",
72
+ "token_expired",
73
+ }
74
+ )
75
+
76
+ # Errors returned by the subscription route itself (inference requests).
77
+ SUBSCRIPTION_INFERENCE_ERROR_HINTS = {
78
+ "subscription_sharing_user_not_eligible": (
79
+ "This ChatGPT account is not eligible to use the ChatGPT plan with "
80
+ "this app. Check your plan in ChatGPT settings."
81
+ ),
82
+ "subscription_sharing_usage_limit_exceeded": (
83
+ "The ChatGPT plan usage limit was reached. Check ChatGPT settings -> "
84
+ "Usage to see the remaining plan usage."
85
+ ),
86
+ "subscription_sharing_usage_unavailable": (
87
+ "ChatGPT plan usage is currently unavailable, please retry later."
88
+ ),
89
+ "subscription_sharing_route_not_supported": (
90
+ "This request is not supported by ChatGPT plan usage."
91
+ ),
92
+ "subscription_sharing_unsupported_capability": (
93
+ "This request uses a capability which ChatGPT plan usage does not support."
94
+ ),
95
+ "subscription_sharing_invalid_user": (
96
+ "The ChatGPT sign-in is no longer valid. Please sign in with ChatGPT again."
97
+ ),
98
+ }
99
+
100
+ # Parameters the ChatGPT plan usage route rejects. Everything else in the
101
+ # request builder is either required (model/input) or accepted.
102
+ SUBSCRIPTION_UNSUPPORTED_PARAMS = (
103
+ "background",
104
+ "conversation",
105
+ "max_output_tokens",
106
+ "max_tool_calls",
107
+ "metadata",
108
+ "moderation",
109
+ "multi_agent",
110
+ "prompt",
111
+ "prompt_cache_key",
112
+ "prompt_cache_options",
113
+ "prompt_cache_retention",
114
+ "safety_identifier",
115
+ "service_tier",
116
+ "temperature",
117
+ "top_logprobs",
118
+ "top_p",
119
+ "truncation",
120
+ "user",
121
+ )
122
+
123
+ # Hosted tools which the subscription route does not provide. Web search is
124
+ # still offered by the route, subject to the account's plan and policy.
125
+ SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES = (
126
+ "code_interpreter",
127
+ "file_search",
128
+ "image_generation",
129
+ "mcp",
130
+ "tool_search",
131
+ )
132
+
133
+ # The subscription route requires unsaved, streamed responses.
134
+ SUBSCRIPTION_STORE = False
135
+ SUBSCRIPTION_STREAM = True
136
+
137
+ # Placeholder value the OpenAI SDK insists on; the real credential is the OAuth
138
+ # access token attached by `ChatGPTSubscriptionClient.custom_auth`.
139
+ PLACEHOLDER_API_KEY = "chatgpt-plan"
140
+
141
+ # Models which were observed in the catalog of a ChatGPT plan account. The
142
+ # catalog is authoritative and is used whenever it can be read, but it can be
143
+ # unavailable (OpenAI answered a transient HTTP 520 while this was written) and
144
+ # the plan usage route rejects the regular API default model, so these are
145
+ # offered as suggestions in that case. The model field stays editable, so a
146
+ # model outside this list can still be used.
147
+ SUBSCRIPTION_MODEL_SUGGESTIONS: list[str] = [
148
+ "gpt-6.1-sol",
149
+ "gpt-6-astra",
150
+ "gpt-6-sol",
151
+ "gpt-6-luna",
152
+ "gpt-reserve",
153
+ "gpt-5.6-sol",
154
+ "gpt-5.6-terra",
155
+ "gpt-5.6-luna",
156
+ "gpt-daybreak-blue-latest",
157
+ "gpt-5.5",
158
+ "codex-auto-review",
159
+ ]
160
+
161
+
162
+ def is_chatgpt_data(data: dict[str, Any]) -> bool:
163
+ """Return if stored data authenticates with a ChatGPT plan.
164
+
165
+ API key data keeps the historical layout and has no auth method key.
166
+ """
167
+ return data.get(CONF_AUTH_METHOD) == AUTH_METHOD_CHATGPT
@@ -0,0 +1,15 @@
1
+ """Errors of the Sign in with ChatGPT flow.
2
+
3
+ Callers map these onto their own error types: :class:`ChatGPTPlanAuthFatalError`
4
+ means the stored refresh token can never be used again and the user has to sign
5
+ in again (Home Assistant starts its reauth flow), every other
6
+ :class:`ChatGPTPlanAuthError` is a transient failure.
7
+ """
8
+
9
+
10
+ class ChatGPTPlanAuthError(Exception):
11
+ """Error of the Sign in with ChatGPT flow."""
12
+
13
+
14
+ class ChatGPTPlanAuthFatalError(ChatGPTPlanAuthError):
15
+ """The stored credentials are dead and the user has to sign in again."""
File without changes
@@ -0,0 +1,101 @@
1
+ """Adapt Responses API requests for the ChatGPT plan usage route.
2
+
3
+ The plan usage route accepts a subset of the Responses API: ``store: false``
4
+ with ``stream: true``, no system input items, no hosted tools beyond web
5
+ search, and none of the parameters listed in ``SUBSCRIPTION_UNSUPPORTED_PARAMS``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .const import (
13
+ SUBSCRIPTION_INFERENCE_ERROR_HINTS,
14
+ SUBSCRIPTION_STORE,
15
+ SUBSCRIPTION_STREAM,
16
+ SUBSCRIPTION_UNSUPPORTED_PARAMS,
17
+ SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES,
18
+ )
19
+
20
+
21
+ def shape_request_for_subscription(
22
+ model_args: dict[str, Any],
23
+ messages: list[Any],
24
+ ) -> None:
25
+ """Adapt a Responses API request for the ChatGPT plan usage route.
26
+
27
+ The subscription route only accepts `store: false` together with
28
+ `stream: true` and rejects a documented list of parameters. It also does
29
+ not accept system input items: the system prompt is sent as `instructions`
30
+ instead. Function tools are accepted flat.
31
+ """
32
+ for param in SUBSCRIPTION_UNSUPPORTED_PARAMS:
33
+ model_args.pop(param, None)
34
+
35
+ model_args["store"] = SUBSCRIPTION_STORE
36
+ model_args["stream"] = SUBSCRIPTION_STREAM
37
+
38
+ # System/developer messages are not accepted as input items.
39
+ instructions = model_args.get("instructions")
40
+ while messages and (
41
+ isinstance(messages[0], dict)
42
+ and messages[0].get("type") == "message"
43
+ and messages[0].get("role") in ("system", "developer")
44
+ ):
45
+ content = messages.pop(0).get("content")
46
+ if isinstance(content, str):
47
+ instructions = f"{instructions}\n\n{content}" if instructions else content
48
+ if instructions:
49
+ model_args["instructions"] = instructions
50
+
51
+ # The route rejects explicit `system` input items.
52
+ for item in messages:
53
+ if (
54
+ isinstance(item, dict)
55
+ and item.get("type") == "message"
56
+ and item.get("role") == "system"
57
+ ):
58
+ item["role"] = "developer"
59
+
60
+ # Hosted tools which the subscription route does not provide are removed;
61
+ # function tools are sent flat and `web_search` is kept.
62
+ if tools := model_args.get("tools"):
63
+ kept = [
64
+ tool
65
+ for tool in tools
66
+ if tool.get("type") == "function"
67
+ or tool.get("type") not in SUBSCRIPTION_UNSUPPORTED_TOOL_TYPES
68
+ ]
69
+ if kept:
70
+ model_args["tools"] = kept
71
+ else:
72
+ model_args.pop("tools")
73
+
74
+ # Naming a single function as `tool_choice` is not supported.
75
+ if isinstance(model_args.get("tool_choice"), dict):
76
+ model_args.pop("tool_choice")
77
+
78
+
79
+ def subscription_error_message(err: Exception) -> str | None:
80
+ """Return a helpful message for a subscription route error.
81
+
82
+ Duck types on ``code`` and only imports the OpenAI SDK when the error is not
83
+ recognised by code, so this module works without the ``openai`` extra.
84
+ """
85
+ code: Any = getattr(err, "code", None)
86
+ if isinstance(code, str) and (
87
+ hint := SUBSCRIPTION_INFERENCE_ERROR_HINTS.get(code)
88
+ ):
89
+ return hint
90
+
91
+ try:
92
+ import openai
93
+ except ImportError:
94
+ return None
95
+
96
+ if isinstance(err, openai.AuthenticationError):
97
+ return SUBSCRIPTION_INFERENCE_ERROR_HINTS["subscription_sharing_invalid_user"]
98
+ return None
99
+
100
+
101
+ __all__ = ["shape_request_for_subscription", "subscription_error_message"]
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: chatgpt-plan-auth
3
+ Version: 1.0.0
4
+ Summary: Sign in with ChatGPT (ChatGPT plan usage) for locally hosted apps
5
+ Author-email: Michael Guber <michael@guber.dev>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: chatgpt,oauth,openai,pkce,siwc
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Provides-Extra: dev
15
+ Requires-Dist: openai>=1.40; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Provides-Extra: openai
19
+ Requires-Dist: openai>=1.40; extra == 'openai'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # chatgpt-plan-auth
23
+
24
+ Sign in with ChatGPT (ChatGPT plan usage) for locally hosted, open-source apps.
25
+
26
+ Implements the SIWC OAuth flow documented at
27
+ <https://developers.openai.com/siwc/token-sharing-open-source/>: register a
28
+ dynamic client, authorize with PKCE, exchange the code for tokens, keep them
29
+ refreshed and rotated, read the plan's model catalog, and shape Responses API
30
+ requests for the plan usage route. The access token is used as the bearer token
31
+ against `https://api.openai.com/v1` instead of an API key.
32
+
33
+ The library is a straight extraction of Home Assistant core's
34
+ `openai_conversation/chatgpt_auth.py` (the reviewer's point: deep knowledge of
35
+ an external system belongs in a library, not in core). It carries no Home
36
+ Assistant imports, no `hass`, no config-entry or storage knowledge, and no
37
+ crypto dependency.
38
+
39
+ ```bash
40
+ pip install chatgpt-plan-auth # protocol only: httpx
41
+ pip install chatgpt-plan-auth[openai] # + the OpenAI SDK client wrapper
42
+ ```
43
+
44
+ ## What lives where
45
+
46
+ | Module | Contents |
47
+ | --- | --- |
48
+ | `chatgpt_plan_auth.const` | endpoints, `dynamic_agent_client`, scopes, the `ext_agent_host_id` scheme, rejected params/tools lists, model suggestions |
49
+ | `chatgpt_plan_auth.auth` | authorization URL + PKCE/state/nonce, callback parsing, token requests, code exchange, ID token claim validation, credentials, token lifecycle |
50
+ | `chatgpt_plan_auth.catalog` | plan model catalog fetch/parse and the suggestion fallback |
51
+ | `chatgpt_plan_auth.requests` | `shape_request_for_subscription`, `subscription_error_message` |
52
+ | `chatgpt_plan_auth.client` | httpx auth flow and `openai.AsyncOpenAI` subclass (extra: `openai`) |
53
+
54
+ Everything is caller-driven: no Home Assistant, no global session, no storage.
55
+
56
+ ```python
57
+ import httpx
58
+ from chatgpt_plan_auth import (
59
+ ChatGPTCredentials, ChatGPTAuth, async_exchange_code, create_authorization_request,
60
+ create_ext_agent_host_id, parse_callback_url,
61
+ )
62
+
63
+ host_id = create_ext_agent_host_id() # generate once, persist it yourself
64
+ request = create_authorization_request(client_id=None, ext_agent_host_id=host_id)
65
+ # send the user to request.url, then read the loopback address bar (any machine):
66
+ params = parse_callback_url(callback_url)
67
+ async with httpx.AsyncClient() as client:
68
+ credentials = await async_exchange_code(
69
+ client, params["code"], request=request,
70
+ callback_client_id=params.get("client_id"), callback_state=params.get("state"),
71
+ )
72
+
73
+ # Later, per request:
74
+ auth = ChatGPTAuth(credentials, client=client, on_update=save) # `save` persists rotation
75
+ token = await auth.async_get_access_token()
76
+ ```
77
+
78
+ Errors: `ChatGPTPlanAuthFatalError` means the stored refresh token can never be
79
+ used again (start your re-authentication flow); any other
80
+ `ChatGPTPlanAuthError` is transient. With the `openai` extra,
81
+ `ChatGPTSubscriptionClient(auth, http_client=...)` attaches the token per
82
+ request and retries once on a 401.
83
+
84
+ ## Deliberate simplification: no ID token signature verification
85
+
86
+ `/.well-known/jwks.json` is never fetched, RSA keys are never parsed and no JWT
87
+ or crypto dependency is carried — the JWKS helpers and their cache from core are
88
+ gone.
89
+
90
+ Rationale: the ID token arrives directly from the provider's
91
+ TLS-authenticated token endpoint in the same response as the code exchange. That
92
+ is the trust model Home Assistant's own OAuth2 flow uses, and it is exactly the
93
+ dependency-heavy part the core reviewer objected to. A signature check adds
94
+ nothing there: anyone able to forge the token response could equally forge the
95
+ signature.
96
+
97
+ What is still verified on every code exchange:
98
+
99
+ - `iss` is `https://auth.openai.com`
100
+ - `aud` contains the client id the code was exchanged with
101
+ - `exp` is in the future
102
+ - `nonce` equals the nonce of that authorization attempt
103
+ - the callback `state` equals the state of that authorization attempt
104
+
105
+ Token endpoint errors (`invalid_grant`, `invalid_client`, `token_expired`, ...)
106
+ still surface as fatal errors and never invalidate good credentials.
107
+
108
+ If you need signature verification — or you cannot trust your network path to
109
+ the token endpoint — verify the ID token against the JWKS in your application
110
+ before calling `validate_id_token`.
111
+
112
+ ## Out of scope (stays in the consuming application)
113
+
114
+ - Home Assistant: config-entry inspection/storage, `Store` for the host id, the
115
+ HA http client, config-flow and reauth steps.
116
+ - Application branding: pass `agent_name_hint` (defaults to
117
+ `chatgpt-plan-auth`).
118
+ - Persisting credentials: the library returns plain mappings
119
+ (`ChatGPTCredentials.as_entry_data()`), nothing is written to disk.
120
+
121
+ ## Tests
122
+
123
+ ```bash
124
+ uv run --with pytest-asyncio --with openai python -m pytest tests -q
125
+ ```
126
+
127
+ All network access is a fake `httpx.MockTransport`; no test touches the network
128
+ and no test needs key material.
@@ -0,0 +1,12 @@
1
+ chatgpt_plan_auth/__init__.py,sha256=Uqhl6MCVksCpiUBGb6TdIx3fiTInDH6Ma4IyaMxOC5Q,2123
2
+ chatgpt_plan_auth/auth.py,sha256=bJKUHK5rVCpNo-PR0VFjYuz4PAZMWEVrwhQUttkJaWQ,19528
3
+ chatgpt_plan_auth/catalog.py,sha256=V6FewEdhguoDgqcq0hEo2iv_D6wapbMGDYokLVyU4M8,2951
4
+ chatgpt_plan_auth/client.py,sha256=LvE7eGZWd5Cy-9olHvq6vrYI-sjpMF-P7r2FxOpcFJE,3210
5
+ chatgpt_plan_auth/const.py,sha256=BP9Ejl6Fef3tuSv8MwBr8Qtni-4dNV_JMOp7L5X4vrI,5957
6
+ chatgpt_plan_auth/errors.py,sha256=5IXUq5XoBl_TCT8moAYWuPfZrmAVjy1SUkt1y1UXptY,551
7
+ chatgpt_plan_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ chatgpt_plan_auth/requests.py,sha256=UXDEY9IW34nyUF7BvWIs8MzmIe4CNlrTl_PlJCbgXYA,3415
9
+ chatgpt_plan_auth-1.0.0.dist-info/METADATA,sha256=-5WIdGTLiacBEuzetMj7neXUkx5zdTJebxC_hAAYNQw,5510
10
+ chatgpt_plan_auth-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ chatgpt_plan_auth-1.0.0.dist-info/licenses/LICENSE,sha256=gTWpUtJRJGTvKKxNlJJ0rKdlUe11k2FkmT93JGljSFI,1070
12
+ chatgpt_plan_auth-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Guber
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.