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 +6 -0
- siwc_proxy/__main__.py +5 -0
- siwc_proxy/auth.py +371 -0
- siwc_proxy/chat.py +456 -0
- siwc_proxy/cli.py +312 -0
- siwc_proxy/config.py +86 -0
- siwc_proxy/constants.py +37 -0
- siwc_proxy/errors.py +89 -0
- siwc_proxy/responses.py +92 -0
- siwc_proxy/server.py +463 -0
- siwc_proxy/sse.py +77 -0
- siwc_proxy/store.py +199 -0
- siwc_proxy-0.2.0.dist-info/METADATA +201 -0
- siwc_proxy-0.2.0.dist-info/RECORD +17 -0
- siwc_proxy-0.2.0.dist-info/WHEEL +4 -0
- siwc_proxy-0.2.0.dist-info/entry_points.txt +2 -0
- siwc_proxy-0.2.0.dist-info/licenses/LICENSE +21 -0
siwc_proxy/__init__.py
ADDED
siwc_proxy/__main__.py
ADDED
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
|