siwc-proxy 0.2.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.
siwc_proxy/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Local OpenAI-compatible proxy for Sign in with ChatGPT token sharing."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ # pyproject.toml is the single source, so `uv version --bump` is all a release needs.
6
+ __version__ = version("siwc-proxy")
siwc_proxy/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m siwc_proxy`, the same as the `siwc-proxy` command."""
2
+
3
+ from .cli import main
4
+
5
+ main(prog_name="siwc-proxy")
siwc_proxy/auth.py ADDED
@@ -0,0 +1,371 @@
1
+ """Sign in with ChatGPT on Authlib: authorization code + PKCE, ID-token checks, refresh, revocation.
2
+
3
+ Authlib does the OAuth/OIDC work (state, PKCE, nonce, discovery, JWKS, token requests). This module
4
+ adds the SIWC rules: the dynamic-client registration swap, `resource` on token requests, identity and
5
+ scope checks, cross-process refresh serialization, and when to keep or clear saved tokens.
6
+ """
7
+
8
+ import logging
9
+ import random
10
+ import secrets
11
+ import time
12
+
13
+ import anyio
14
+ import httpx
15
+ from authlib.integrations.base_client import OAuthError
16
+ from authlib.integrations.httpx_client import AsyncOAuth2Client
17
+ from authlib.integrations.starlette_client import OAuth
18
+ from authlib.oauth2.rfc6749 import OAuth2Token
19
+ from joserfc.errors import JoseError
20
+ from starlette.requests import Request
21
+
22
+ from .constants import (AGENT_NAME, DYNAMIC_CLIENT, ISSUER, LEEWAY, METADATA_URL, RESOURCE, SCOPES,
23
+ TERMINAL_REFRESH_ERRORS)
24
+ from .errors import OpenAIError
25
+ from .store import Account, Record
26
+
27
+ log = logging.getLogger("siwc_proxy.auth")
28
+
29
+ LAUNCH_TTL = 900
30
+ BACKOFF_BASE = 0.5
31
+
32
+
33
+ def backoff(attempt):
34
+ """Jittered exponential delay for attempt 1, 2, ..."""
35
+ return min(8.0, BACKOFF_BASE * 2 ** (attempt - 1)) * (0.5 + random.random() / 2)
36
+
37
+
38
+ def login_required(message):
39
+ return OpenAIError(message, status=401, code="chatgpt_login_required", error_type="authentication_error")
40
+
41
+
42
+ def consent_required(message):
43
+ return OpenAIError(message, status=403, code="chatgpt_consent_required", error_type="permission_error")
44
+
45
+
46
+ def refresh_failed(timeout=False):
47
+ return OpenAIError("OpenAI could not refresh the ChatGPT session; saved credentials were kept. Try again.",
48
+ status=504 if timeout else 502, code="credential_refresh_failed")
49
+
50
+
51
+ class SignInError(Exception):
52
+ """A browser sign-in that did not complete; the message is shown on the callback page."""
53
+
54
+
55
+ class MemoryCache:
56
+ """Server-side Authlib state (PKCE verifier, nonce); the session cookie only carries a marker."""
57
+
58
+ def __init__(self):
59
+ self._items = {}
60
+
61
+ async def get(self, key):
62
+ value, expires = self._items.get(key, (None, 0))
63
+ if expires < time.monotonic():
64
+ self._items.pop(key, None)
65
+ return None
66
+ return value
67
+
68
+ async def set(self, key, value, expires=None):
69
+ now = time.monotonic()
70
+ self._items = {k: v for k, v in self._items.items() if v[1] >= now}
71
+ self._items[key] = (value, now + (expires or LAUNCH_TTL))
72
+
73
+ async def delete(self, key):
74
+ self._items.pop(key, None)
75
+
76
+
77
+ class ChatGPTAuth:
78
+ def __init__(self, settings, store, *, transport=None, timeout=30):
79
+ self.settings, self.store = settings, store
80
+ self.http = {"trust_env": False, "timeout": timeout}
81
+ if transport is not None:
82
+ self.http["transport"] = transport
83
+ self.cache = MemoryCache()
84
+ self.metadata = {}
85
+ self.launches = {}
86
+ self.serving = False
87
+ self.signed_in = None # anyio.Event set by a completed browser sign-in while serving
88
+
89
+ # Authlib clients
90
+
91
+ def remote(self, client_id):
92
+ """Authlib app for one client_id; all share the state cache and the loaded discovery/JWKS."""
93
+ remote = OAuth(cache=self.cache).register(
94
+ "chatgpt",
95
+ client_id=client_id,
96
+ server_metadata_url=METADATA_URL,
97
+ access_token_params={"resource": RESOURCE},
98
+ client_kwargs={"scope": SCOPES, "code_challenge_method": "S256",
99
+ "token_endpoint_auth_method": "none", **self.http},
100
+ )
101
+ remote.server_metadata = self.metadata
102
+ remote.framework.expires_in = LAUNCH_TTL
103
+ return remote
104
+
105
+ async def server_metadata(self):
106
+ return await self.remote(DYNAMIC_CLIENT).load_server_metadata()
107
+
108
+ @staticmethod
109
+ def claims_options(client_id):
110
+ return {"iss": {"essential": True, "value": ISSUER},
111
+ "aud": {"essential": True, "value": client_id},
112
+ "sub": {"essential": True}}
113
+
114
+ def client(self, account, **kwargs):
115
+ return AsyncOAuth2Client(client_id=account.client_id, token_endpoint_auth_method="none",
116
+ revocation_endpoint_auth_method="none", leeway=LEEWAY, **self.http, **kwargs)
117
+
118
+ # Browser sign-in
119
+
120
+ def login_url(self, consent=False):
121
+ if not self.serving:
122
+ return None
123
+ now = time.monotonic()
124
+ self.launches = {k: v for k, v in self.launches.items() if v > now + 60}
125
+ launch = next(iter(self.launches), None)
126
+ if launch is None:
127
+ launch = secrets.token_urlsafe(24)
128
+ self.launches[launch] = now + LAUNCH_TTL
129
+ return f"{self.settings.origin}/auth/start/{launch}" + ("?consent=true" if consent else "")
130
+
131
+ def check_launch(self, launch):
132
+ now = time.monotonic()
133
+ return any(secrets.compare_digest(launch.encode(), k.encode()) and v > now for k, v in self.launches.items())
134
+
135
+ def login_hint(self, consent=False):
136
+ url = self.login_url(consent)
137
+ command = "siwc-proxy login" + (" --consent" if consent else "")
138
+ return f"open {url} or run `{command}`" if url else f"run `{command}`"
139
+
140
+ async def authorize_redirect(self, request, *, consent=False):
141
+ async with self.store.transaction():
142
+ record = self.store.read()
143
+ if record is None:
144
+ record = Record()
145
+ self.store.write(record) # ext_agent_host_id must stay stable across sign-ins
146
+ account = record.account
147
+ client_id = account.client_id if account else DYNAMIC_CLIENT
148
+ params = {"resource": RESOURCE, "ext_agent_host_id": record.ext_agent_host_id}
149
+ if client_id == DYNAMIC_CLIENT:
150
+ params["agent_name_hint"] = AGENT_NAME
151
+ if account and account.email:
152
+ params["login_hint"] = account.email
153
+ if account and account.id_token:
154
+ params["id_token_hint"] = account.id_token
155
+ if consent:
156
+ params["prompt"] = "consent"
157
+ return await self.remote(client_id).authorize_redirect(request, self.settings.redirect_uri, **params)
158
+
159
+ async def complete_sign_in(self, request):
160
+ """Finish the callback and save the account; returns the saved record."""
161
+ query = request.query_params
162
+ record = self.store.read() or Record()
163
+ account = record.account
164
+ expected = account.client_id if account else DYNAMIC_CLIENT
165
+ # A first sign-in returns the client_id issued to this installation; later ones must keep it.
166
+ issued = query.get("client_id") or expected
167
+ if account and issued != account.client_id:
168
+ raise SignInError("OpenAI returned a different registration than the saved one. Sign-in was not saved.")
169
+ remote = self.remote(issued)
170
+ state = query.get("state")
171
+ # Checked first so a callback from another browser does not consume the real one's state.
172
+ if not state or await remote.framework.get_state_data(request.session, state) is None:
173
+ raise SignInError("This sign-in is invalid, expired, or was started in another browser. Start again.")
174
+ try:
175
+ token = await remote.authorize_access_token(
176
+ request, claims_options=self.claims_options(issued), leeway=LEEWAY)
177
+ except OAuthError as error:
178
+ log.warning("Sign-in failed: %s", error.error)
179
+ if error.error == "access_denied":
180
+ raise SignInError("Sign-in was cancelled.") from None
181
+ raise SignInError("OpenAI did not complete the sign-in. Start again.") from None
182
+ except (JoseError, ValueError, KeyError, TypeError) as error:
183
+ log.warning("Sign-in failed: invalid ID token (%s)", type(error).__name__)
184
+ raise SignInError("OpenAI returned an identity token that could not be verified.") from None
185
+ except httpx.HTTPError as error:
186
+ log.warning("Sign-in failed: %s", type(error).__name__)
187
+ raise SignInError("Could not reach OpenAI to finish sign-in. Start again.") from None
188
+ claims = token.pop("userinfo", None)
189
+ if not claims or not token.get("access_token"):
190
+ raise SignInError("OpenAI did not return an identity token. Start again.")
191
+ if account and account.subject and claims["sub"] != account.subject:
192
+ raise SignInError("That is a different ChatGPT account than the saved one. "
193
+ "Run `siwc-proxy logout --forget` to switch accounts.")
194
+ async with self.store.transaction():
195
+ current = self.store.read() or record
196
+ saved = current.account if current.account and current.account.client_id == issued else None
197
+ saved = saved or Account(client_id=issued)
198
+ saved.subject, saved.issuer = claims["sub"], ISSUER
199
+ saved.email, saved.name = claims.get("email"), claims.get("name")
200
+ # A code exchange that omits scope granted what was requested (RFC 6749 §5.1).
201
+ saved.apply_tokens(token, default_scope=SCOPES)
202
+ current.account = saved
203
+ self.store.write(current)
204
+ self.launches.clear()
205
+ log.info("Signed in to ChatGPT (%s).", current.state)
206
+ if self.signed_in is not None:
207
+ self.signed_in.set()
208
+ return current
209
+
210
+ # Pasted sign-in: the browser runs on another computer, so its callback address is pasted back by hand.
211
+
212
+ @staticmethod
213
+ def pasted_request(session, query=""):
214
+ """A stand-in for the browser's request; `session` plays the cookie that binds the OAuth state."""
215
+ return Request({"type": "http", "method": "GET", "headers": [], "query_string": query.encode(),
216
+ "session": session})
217
+
218
+ async def pasted_sign_in_url(self, session, consent=False):
219
+ """OpenAI's authorize URL, with the state kept in `session` for `complete_sign_in`."""
220
+ response = await self.authorize_redirect(self.pasted_request(session), consent=consent)
221
+ return response.headers["location"]
222
+
223
+ # Tokens
224
+
225
+ def require_ready(self, record):
226
+ state = record.state if record else "signed_out"
227
+ if state == "ready":
228
+ return record.account
229
+ if state == "consent_required":
230
+ raise consent_required("ChatGPT plan usage is not allowed for this sign-in; " + self.login_hint(True) + ".")
231
+ raise login_required("Sign in with ChatGPT: " + self.login_hint() + ".")
232
+
233
+ @staticmethod
234
+ def refresh_due(account):
235
+ if not OAuth2Token(account.token()).is_expired(leeway=LEEWAY):
236
+ return False # Unknown expiry counts as valid; an upstream 401 forces a refresh.
237
+ now = time.time()
238
+ # Honor earliest_refresh_at while the current token still works.
239
+ return not (account.earliest_refresh_at and now < account.earliest_refresh_at
240
+ and account.expires_at and now < account.expires_at)
241
+
242
+ async def access_token(self, *, force=False, rejected=None):
243
+ """A usable access token, refreshing at most once across threads and processes.
244
+
245
+ `force` refreshes even before expiry (after an upstream 401); `rejected` is the token that was
246
+ refused, so a token another caller already replaced is reused instead of refreshed again.
247
+ """
248
+ account = self.require_ready(self.store.read())
249
+ if not force and not self.refresh_due(account):
250
+ return account.access_token
251
+ async with self.store.transaction():
252
+ record = self.store.read()
253
+ account = self.require_ready(record)
254
+ if force and rejected is not None and account.access_token != rejected:
255
+ return account.access_token
256
+ if not force and not self.refresh_due(account):
257
+ return account.access_token
258
+ await self._refresh(record)
259
+ return self.require_ready(record).access_token
260
+
261
+ async def _refresh(self, record):
262
+ account = record.account
263
+ try:
264
+ metadata = await self.server_metadata()
265
+ except (httpx.HTTPError, ValueError) as error:
266
+ log.warning("Refresh failed: discovery %s", type(error).__name__)
267
+ raise refresh_failed(isinstance(error, httpx.TimeoutException)) from None
268
+ previous_scope, issued = account.scope, {}
269
+
270
+ async def persist(token, refresh_token=None, access_token=None):
271
+ # Saved before any other check: the old refresh token is already spent.
272
+ if not token.get("access_token"):
273
+ raise ValueError("token response without access_token")
274
+ account.apply_tokens(token, default_scope=previous_scope)
275
+ self.store.write(record)
276
+ issued.update(token)
277
+
278
+ try:
279
+ async with self.client(account, token=account.token(), update_token=persist) as client:
280
+ await client.refresh_token(metadata["token_endpoint"], refresh_token=account.refresh_token,
281
+ resource=RESOURCE)
282
+ except OAuthError as error:
283
+ if error.error in TERMINAL_REFRESH_ERRORS:
284
+ log.warning("Refresh rejected (%s); sign-in required.", error.error)
285
+ account.clear_tokens()
286
+ self.store.write(record)
287
+ raise login_required(f"The ChatGPT session ended ({error.error}). Sign in again: "
288
+ + self.login_hint() + ".") from None
289
+ if error.error == "invalid_client":
290
+ log.warning("Refresh rejected (invalid_client); saved credentials were kept.")
291
+ raise OpenAIError("OpenAI rejected this proxy's client registration (invalid_client); saved "
292
+ "credentials were kept. If it persists, run `siwc-proxy logout --forget` and sign "
293
+ "in again.", status=502, code="chatgpt_client_rejected") from None
294
+ log.warning("Refresh failed: %s", error.error)
295
+ raise refresh_failed() from None
296
+ except (httpx.HTTPError, ValueError, KeyError, TypeError) as error:
297
+ log.warning("Refresh failed: %s", type(error).__name__)
298
+ raise refresh_failed(isinstance(error, httpx.TimeoutException)) from None
299
+ log.info("Refreshed the ChatGPT access token.")
300
+ if issued.get("id_token"):
301
+ await self._check_identity(record, issued)
302
+
303
+ async def _check_identity(self, record, token):
304
+ account = record.account
305
+ try:
306
+ claims = await self.remote(account.client_id).parse_id_token(
307
+ token, nonce=None, claims_options=self.claims_options(account.client_id), leeway=LEEWAY)
308
+ except (JoseError, ValueError, KeyError, TypeError, httpx.HTTPError) as error:
309
+ log.warning("Could not verify the refreshed ID token (%s); keeping tokens.", type(error).__name__)
310
+ return
311
+ if account.subject and claims["sub"] != account.subject:
312
+ log.warning("Refreshed identity changed; clearing tokens.")
313
+ account.clear_tokens()
314
+ self.store.write(record)
315
+ raise login_required("The ChatGPT identity changed. Sign in again: " + self.login_hint() + ".")
316
+
317
+ # Sign-out and status
318
+
319
+ async def logout(self, *, forget=False):
320
+ """Revoke and clear tokens, keeping the registration unless `forget`.
321
+
322
+ Returns True when revocation was confirmed, False when it failed, None when nothing was signed in.
323
+ """
324
+ async with self.store.transaction():
325
+ record = self.store.read()
326
+ account = record.account if record else None
327
+ revoked = None
328
+ if account and (account.refresh_token or account.access_token):
329
+ revoked = await self._revoke(account)
330
+ if forget:
331
+ self.store.delete()
332
+ elif account:
333
+ account.clear_tokens()
334
+ self.store.write(record)
335
+ return revoked
336
+
337
+ async def _revoke(self, account):
338
+ token, hint = ((account.refresh_token, "refresh_token") if account.refresh_token
339
+ else (account.access_token, "access_token"))
340
+ attempts = 4
341
+ for attempt in range(1, attempts + 1):
342
+ try:
343
+ url = (await self.server_metadata()).get("revocation_endpoint")
344
+ if not url:
345
+ log.warning("OpenAI does not advertise a revocation endpoint; tokens were removed locally.")
346
+ return False
347
+ async with self.client(account) as client:
348
+ response = await client.revoke_token(url, token=token, token_type_hint=hint)
349
+ if response.is_success:
350
+ return True
351
+ if response.status_code < 500:
352
+ log.warning("OpenAI rejected token revocation (HTTP %s).", response.status_code)
353
+ return False
354
+ except httpx.TransportError as error:
355
+ log.warning("Revocation attempt %s failed: %s", attempt, type(error).__name__)
356
+ if attempt < attempts:
357
+ await anyio.sleep(backoff(attempt))
358
+ log.warning("Token revocation did not complete; tokens were removed locally.")
359
+ return False
360
+
361
+ def status(self):
362
+ record = self.store.read()
363
+ account = record.account if record else None
364
+ result = {"state": record.state if record else "signed_out", "registered": account is not None}
365
+ if account:
366
+ result.update(email=account.email, name=account.name, subject=account.subject,
367
+ client_id=account.client_id, scopes=sorted(account.scopes),
368
+ expires_at=account.expires_at, earliest_refresh_at=account.earliest_refresh_at)
369
+ if result["state"] != "ready" and self.serving:
370
+ result["login_url"] = self.login_url(consent=result["state"] == "consent_required")
371
+ return result