rosa-llm 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.
- rosa_llm/__init__.py +36 -0
- rosa_llm/__main__.py +3 -0
- rosa_llm/auth.py +497 -0
- rosa_llm/cli.py +273 -0
- rosa_llm/client.py +252 -0
- rosa_llm/config.py +109 -0
- rosa_llm/errors.py +61 -0
- rosa_llm/http.py +167 -0
- rosa_llm/langchain.py +453 -0
- rosa_llm/provenance.py +62 -0
- rosa_llm/providers.py +494 -0
- rosa_llm/storage.py +112 -0
- rosa_llm/types.py +117 -0
- rosa_llm-0.2.0.dist-info/METADATA +349 -0
- rosa_llm-0.2.0.dist-info/RECORD +18 -0
- rosa_llm-0.2.0.dist-info/WHEEL +4 -0
- rosa_llm-0.2.0.dist-info/entry_points.txt +3 -0
- rosa_llm-0.2.0.dist-info/licenses/LICENSE +202 -0
rosa_llm/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Public API for the standalone rosa-llm package."""
|
|
2
|
+
|
|
3
|
+
from .client import Client, create_client
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .errors import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConfigurationError,
|
|
8
|
+
IncompleteResponse,
|
|
9
|
+
LLMError,
|
|
10
|
+
PermissionDenied,
|
|
11
|
+
ProviderError,
|
|
12
|
+
UnsupportedFeature,
|
|
13
|
+
UsageLimitError,
|
|
14
|
+
)
|
|
15
|
+
from .provenance import package_info
|
|
16
|
+
from .types import Message, Response, StreamEvent, Tool, ToolCall
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AuthenticationError",
|
|
20
|
+
"Client",
|
|
21
|
+
"Config",
|
|
22
|
+
"ConfigurationError",
|
|
23
|
+
"IncompleteResponse",
|
|
24
|
+
"LLMError",
|
|
25
|
+
"Message",
|
|
26
|
+
"PermissionDenied",
|
|
27
|
+
"ProviderError",
|
|
28
|
+
"Response",
|
|
29
|
+
"StreamEvent",
|
|
30
|
+
"Tool",
|
|
31
|
+
"ToolCall",
|
|
32
|
+
"UnsupportedFeature",
|
|
33
|
+
"UsageLimitError",
|
|
34
|
+
"create_client",
|
|
35
|
+
"package_info",
|
|
36
|
+
]
|
rosa_llm/__main__.py
ADDED
rosa_llm/auth.py
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
"""Official public-client ChatGPT plan OAuth. See docs/protocol.md for sources."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
import webbrowser
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
10
|
+
from typing import Callable
|
|
11
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import jwt
|
|
15
|
+
|
|
16
|
+
from .errors import AuthenticationError, LLMError, PermissionDenied
|
|
17
|
+
from .http import json_object, safe_identifier
|
|
18
|
+
from .storage import CredentialStore
|
|
19
|
+
|
|
20
|
+
ISSUER = "https://auth.openai.com"
|
|
21
|
+
AUTHORIZE = ISSUER + "/api/accounts/authorize"
|
|
22
|
+
TOKEN = ISSUER + "/api/accounts/oauth/token"
|
|
23
|
+
DISCOVERY = ISSUER + "/.well-known/openid-configuration"
|
|
24
|
+
RESOURCE = "https://api.openai.com/v1"
|
|
25
|
+
SCOPES = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"
|
|
26
|
+
PLAN_SCOPES = {"resource.invoke", "chatgpt.tokens.use.direct"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(repr=False)
|
|
30
|
+
class PendingLogin:
|
|
31
|
+
client_id: str
|
|
32
|
+
redirect_uri: str
|
|
33
|
+
host_id: str
|
|
34
|
+
state: str = field(default_factory=lambda: secrets.token_urlsafe(32))
|
|
35
|
+
nonce: str = field(default_factory=lambda: secrets.token_urlsafe(32))
|
|
36
|
+
verifier: str = field(default_factory=lambda: secrets.token_urlsafe(64))
|
|
37
|
+
created_at: float = field(default_factory=time.time)
|
|
38
|
+
consumed: bool = False
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def challenge(self) -> str:
|
|
42
|
+
return (
|
|
43
|
+
base64.urlsafe_b64encode(hashlib.sha256(self.verifier.encode()).digest())
|
|
44
|
+
.rstrip(b"=")
|
|
45
|
+
.decode()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def url(
|
|
49
|
+
self, previous: dict | None = None, *, hints: bool = False, consent: bool = False
|
|
50
|
+
) -> str:
|
|
51
|
+
params = {
|
|
52
|
+
"client_id": self.client_id,
|
|
53
|
+
"ext_agent_host_id": self.host_id,
|
|
54
|
+
"response_type": "code",
|
|
55
|
+
"redirect_uri": self.redirect_uri,
|
|
56
|
+
"scope": SCOPES,
|
|
57
|
+
"resource": RESOURCE,
|
|
58
|
+
"state": self.state,
|
|
59
|
+
"nonce": self.nonce,
|
|
60
|
+
"code_challenge_method": "S256",
|
|
61
|
+
"code_challenge": self.challenge,
|
|
62
|
+
}
|
|
63
|
+
if self.client_id == "dynamic_agent_client":
|
|
64
|
+
params["agent_name_hint"] = "rosa-llm"
|
|
65
|
+
elif previous and hints:
|
|
66
|
+
# Never print URLs containing the retained ID token.
|
|
67
|
+
if previous.get("id_token"):
|
|
68
|
+
params["id_token_hint"] = previous["id_token"]
|
|
69
|
+
if previous.get("email"):
|
|
70
|
+
params["login_hint"] = previous["email"]
|
|
71
|
+
if consent:
|
|
72
|
+
params["prompt"] = "consent"
|
|
73
|
+
return AUTHORIZE + "?" + urlencode(params)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_callback(query: str, pending: PendingLogin) -> tuple[str, str]:
|
|
77
|
+
if pending.consumed or time.time() - pending.created_at > 300:
|
|
78
|
+
raise AuthenticationError("Login attempt expired or already consumed; start login again.")
|
|
79
|
+
pending.consumed = True
|
|
80
|
+
params = parse_qs(query, keep_blank_values=True)
|
|
81
|
+
if any(len(values) != 1 for values in params.values()):
|
|
82
|
+
raise AuthenticationError("OAuth callback contains duplicate parameters.")
|
|
83
|
+
state = params.get("state", [""])[0]
|
|
84
|
+
if not secrets.compare_digest(state.encode(), pending.state.encode()):
|
|
85
|
+
raise AuthenticationError("OAuth state mismatch; start login again.")
|
|
86
|
+
if "error" in params:
|
|
87
|
+
raise PermissionDenied("Authorization was denied; no credentials were changed.")
|
|
88
|
+
client_id = params.get("client_id", [pending.client_id])[0]
|
|
89
|
+
if pending.client_id == "dynamic_agent_client":
|
|
90
|
+
if not client_id or client_id == "dynamic_agent_client":
|
|
91
|
+
raise AuthenticationError("Registration callback did not supply an issued client ID.")
|
|
92
|
+
elif client_id != pending.client_id:
|
|
93
|
+
raise AuthenticationError("Callback client ID does not match the selected registration.")
|
|
94
|
+
code = params.get("code", [""])[0]
|
|
95
|
+
if not code:
|
|
96
|
+
raise AuthenticationError("OAuth callback did not contain a code.")
|
|
97
|
+
return code, client_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OAuthManager:
|
|
101
|
+
def __init__(self, store: CredentialStore, http: httpx.Client, profile: str = "default"):
|
|
102
|
+
self.store, self.http, self.profile = store, http, profile
|
|
103
|
+
|
|
104
|
+
def _record(self) -> dict:
|
|
105
|
+
record = self.store.read("credentials.json").get(self.profile, {})
|
|
106
|
+
if not isinstance(record, dict):
|
|
107
|
+
raise AuthenticationError(
|
|
108
|
+
"Invalid saved profile; restore credentials or authorize again."
|
|
109
|
+
)
|
|
110
|
+
if record:
|
|
111
|
+
if not all(
|
|
112
|
+
isinstance(record.get(key), str) and record[key] for key in ("client_id", "subject")
|
|
113
|
+
):
|
|
114
|
+
raise AuthenticationError("Saved profile is missing its registration identity.")
|
|
115
|
+
if (
|
|
116
|
+
not isinstance(record.get("scopes", []), list)
|
|
117
|
+
or not all(isinstance(scope, str) for scope in record.get("scopes", []))
|
|
118
|
+
or not isinstance(record.get("expires_at", 0), (float, int))
|
|
119
|
+
):
|
|
120
|
+
raise AuthenticationError("Saved profile has invalid scopes or expiry information.")
|
|
121
|
+
return record
|
|
122
|
+
|
|
123
|
+
def _invalidate(self, record: dict):
|
|
124
|
+
record = {
|
|
125
|
+
k: v
|
|
126
|
+
for k, v in record.items()
|
|
127
|
+
if k not in {"access_token", "refresh_token", "id_token", "pending_id_token"}
|
|
128
|
+
}
|
|
129
|
+
record["needs_login"] = True
|
|
130
|
+
self._save(record)
|
|
131
|
+
|
|
132
|
+
def _validate_pending_identity(self, record: dict):
|
|
133
|
+
token = record.get("pending_id_token")
|
|
134
|
+
if not token:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
claims = self.validate_identity(token, record["client_id"])
|
|
138
|
+
if claims["sub"] != record["subject"]:
|
|
139
|
+
raise AuthenticationError("Refreshed token identity changed; authorize again.")
|
|
140
|
+
except AuthenticationError:
|
|
141
|
+
self._invalidate(record)
|
|
142
|
+
raise
|
|
143
|
+
# Network/JWKS outages leave the rotated tokens pending and do not permit inference.
|
|
144
|
+
record["id_token"] = record.pop("pending_id_token")
|
|
145
|
+
self._save(record)
|
|
146
|
+
|
|
147
|
+
def _save(self, record: dict):
|
|
148
|
+
records = self.store.read("credentials.json")
|
|
149
|
+
records[self.profile] = record
|
|
150
|
+
self.store.write("credentials.json", records)
|
|
151
|
+
|
|
152
|
+
def status(self) -> dict:
|
|
153
|
+
with self.store.lock():
|
|
154
|
+
record = self._record()
|
|
155
|
+
pending_validation = bool(record.get("pending_id_token"))
|
|
156
|
+
authenticated = bool(record.get("access_token")) and not pending_validation
|
|
157
|
+
return {
|
|
158
|
+
"profile": self.profile,
|
|
159
|
+
"authenticated": authenticated,
|
|
160
|
+
"identity_authenticated": bool(record.get("id_token")),
|
|
161
|
+
"identity_validation_pending": pending_validation,
|
|
162
|
+
"plan_permission": authenticated and PLAN_SCOPES.issubset(record.get("scopes", [])),
|
|
163
|
+
"expired": record.get("expires_at", 0) <= time.time(),
|
|
164
|
+
"renewable": bool(record.get("refresh_token")),
|
|
165
|
+
"remote_permission_checked": False,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
def _discovery(self) -> dict:
|
|
169
|
+
doc = json_object(self.http.get(DISCOVERY, follow_redirects=False))
|
|
170
|
+
if doc.get("issuer") != ISSUER:
|
|
171
|
+
raise AuthenticationError("OIDC discovery issuer mismatch.")
|
|
172
|
+
# Discovery cannot redirect secrets to an arbitrary origin.
|
|
173
|
+
for name in ("jwks_uri", "revocation_endpoint"):
|
|
174
|
+
endpoint = doc.get(name)
|
|
175
|
+
if endpoint and (
|
|
176
|
+
urlsplit(endpoint).scheme != "https"
|
|
177
|
+
or urlsplit(endpoint).netloc != "auth.openai.com"
|
|
178
|
+
):
|
|
179
|
+
raise AuthenticationError("OIDC discovery returned an untrusted endpoint.")
|
|
180
|
+
return doc
|
|
181
|
+
|
|
182
|
+
def validate_identity(self, token: str, client_id: str, nonce: str | None = None) -> dict:
|
|
183
|
+
try:
|
|
184
|
+
doc = self._discovery()
|
|
185
|
+
jwks = json_object(self.http.get(doc["jwks_uri"], follow_redirects=False))
|
|
186
|
+
header = jwt.get_unverified_header(token)
|
|
187
|
+
# Algorithms are chosen by the application, never trusted from the JWT alone.
|
|
188
|
+
if header.get("alg") != "RS256":
|
|
189
|
+
raise ValueError
|
|
190
|
+
keys = [
|
|
191
|
+
key
|
|
192
|
+
for key in jwks["keys"]
|
|
193
|
+
if key.get("kid") == header.get("kid")
|
|
194
|
+
and key.get("kty") == "RSA"
|
|
195
|
+
and key.get("use", "sig") == "sig"
|
|
196
|
+
]
|
|
197
|
+
if len(keys) != 1:
|
|
198
|
+
raise ValueError
|
|
199
|
+
key = jwt.PyJWK.from_dict(keys[0], algorithm="RS256")
|
|
200
|
+
claims = jwt.decode(
|
|
201
|
+
token,
|
|
202
|
+
key.key,
|
|
203
|
+
algorithms=["RS256"],
|
|
204
|
+
issuer=ISSUER,
|
|
205
|
+
audience=client_id,
|
|
206
|
+
leeway=5,
|
|
207
|
+
options={"require": ["exp", "iat", "sub", "iss", "aud"]},
|
|
208
|
+
)
|
|
209
|
+
if not isinstance(claims["sub"], str) or not claims["sub"]:
|
|
210
|
+
raise ValueError
|
|
211
|
+
if nonce is not None and (
|
|
212
|
+
not isinstance(claims.get("nonce"), str)
|
|
213
|
+
or not secrets.compare_digest(claims["nonce"], nonce)
|
|
214
|
+
):
|
|
215
|
+
raise ValueError
|
|
216
|
+
if isinstance(claims["aud"], list) and len(claims["aud"]) > 1:
|
|
217
|
+
if claims.get("azp") != client_id:
|
|
218
|
+
raise ValueError
|
|
219
|
+
return claims
|
|
220
|
+
except (jwt.PyJWTError, KeyError, TypeError, ValueError):
|
|
221
|
+
raise AuthenticationError(
|
|
222
|
+
"ID token signature, identity, expiry, or nonce is invalid."
|
|
223
|
+
) from None
|
|
224
|
+
|
|
225
|
+
def _tokens(self, form: dict) -> dict:
|
|
226
|
+
response = self.http.post(TOKEN, data=form, follow_redirects=False)
|
|
227
|
+
if not response.is_success:
|
|
228
|
+
try:
|
|
229
|
+
error = response.json().get("error")
|
|
230
|
+
except (ValueError, AttributeError):
|
|
231
|
+
error = None
|
|
232
|
+
if isinstance(error, dict):
|
|
233
|
+
error = error.get("code")
|
|
234
|
+
if isinstance(error, str) and error in {
|
|
235
|
+
"invalid_grant",
|
|
236
|
+
"invalid_client",
|
|
237
|
+
"invalid_refresh_token",
|
|
238
|
+
"token_expired",
|
|
239
|
+
"refresh_token_expired",
|
|
240
|
+
"refresh_token_invalidated",
|
|
241
|
+
"refresh_token_reused",
|
|
242
|
+
}:
|
|
243
|
+
raise AuthenticationError(
|
|
244
|
+
"Session or authorization code is invalid; run rosa-llm login.",
|
|
245
|
+
status_code=response.status_code,
|
|
246
|
+
provider_code=safe_identifier(error),
|
|
247
|
+
request_id=safe_identifier(response.headers.get("x-request-id")),
|
|
248
|
+
kind="http_error",
|
|
249
|
+
)
|
|
250
|
+
return json_object(response)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def _token_record(tokens: dict, old: dict | None = None) -> dict:
|
|
254
|
+
old = old or {}
|
|
255
|
+
if (
|
|
256
|
+
not isinstance(tokens.get("access_token"), str)
|
|
257
|
+
or not tokens["access_token"]
|
|
258
|
+
or str(tokens.get("token_type", "")).lower() != "bearer"
|
|
259
|
+
):
|
|
260
|
+
raise AuthenticationError("Token endpoint returned an invalid access token response.")
|
|
261
|
+
try:
|
|
262
|
+
lifetime = float(tokens["expires_in"])
|
|
263
|
+
if not 0 < lifetime <= 86400:
|
|
264
|
+
raise ValueError
|
|
265
|
+
except (KeyError, ValueError, TypeError):
|
|
266
|
+
raise AuthenticationError(
|
|
267
|
+
"Token endpoint returned invalid expiry information."
|
|
268
|
+
) from None
|
|
269
|
+
if not isinstance(tokens.get("refresh_token"), str) or not tokens["refresh_token"]:
|
|
270
|
+
raise AuthenticationError(
|
|
271
|
+
"No rotating refresh token was returned; authorize offline access."
|
|
272
|
+
)
|
|
273
|
+
scope = tokens.get("scope")
|
|
274
|
+
scopes = scope.split() if isinstance(scope, str) else old.get("scopes", [])
|
|
275
|
+
if not PLAN_SCOPES.issubset(scopes):
|
|
276
|
+
raise PermissionDenied(
|
|
277
|
+
"Identity login succeeded but ChatGPT plan inference was not granted."
|
|
278
|
+
)
|
|
279
|
+
return {
|
|
280
|
+
**old,
|
|
281
|
+
"access_token": tokens["access_token"],
|
|
282
|
+
"refresh_token": tokens["refresh_token"],
|
|
283
|
+
"token_type": "Bearer",
|
|
284
|
+
"expires_at": time.time() + lifetime,
|
|
285
|
+
"scopes": scopes,
|
|
286
|
+
"saved_at": time.time(),
|
|
287
|
+
"needs_login": False,
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
def finish_login(self, pending: PendingLogin, query: str, previous: dict | None = None):
|
|
291
|
+
code, client_id = parse_callback(query, pending)
|
|
292
|
+
tokens = self._tokens(
|
|
293
|
+
{
|
|
294
|
+
"grant_type": "authorization_code",
|
|
295
|
+
"client_id": client_id,
|
|
296
|
+
"code": code,
|
|
297
|
+
"code_verifier": pending.verifier,
|
|
298
|
+
"redirect_uri": pending.redirect_uri,
|
|
299
|
+
"resource": RESOURCE,
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
claims = self.validate_identity(tokens.get("id_token", ""), client_id, pending.nonce)
|
|
303
|
+
if previous and (
|
|
304
|
+
claims["sub"] != previous["subject"] or client_id != previous["client_id"]
|
|
305
|
+
):
|
|
306
|
+
raise AuthenticationError(
|
|
307
|
+
"Signed-in identity differs from the selected profile; use a new profile."
|
|
308
|
+
)
|
|
309
|
+
identity = {
|
|
310
|
+
"subject": claims["sub"],
|
|
311
|
+
"email": claims.get("email"),
|
|
312
|
+
"issuer": ISSUER,
|
|
313
|
+
"client_id": client_id,
|
|
314
|
+
"id_token": tokens["id_token"],
|
|
315
|
+
"ext_agent_host_id": pending.host_id,
|
|
316
|
+
}
|
|
317
|
+
scope = tokens.get("scope")
|
|
318
|
+
if not isinstance(scope, str) or not PLAN_SCOPES.issubset(scope.split()):
|
|
319
|
+
# Retain a new identity for reauthorization; preserve a valid old session.
|
|
320
|
+
with self.store.lock():
|
|
321
|
+
if self._record() != (previous or {}):
|
|
322
|
+
raise AuthenticationError("Profile changed during login; start login again.")
|
|
323
|
+
if not previous or not previous.get("access_token"):
|
|
324
|
+
self._save(
|
|
325
|
+
{
|
|
326
|
+
**identity,
|
|
327
|
+
"scopes": scope.split() if isinstance(scope, str) else [],
|
|
328
|
+
"expires_at": 0,
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
raise PermissionDenied(
|
|
332
|
+
"Identity login succeeded but plan inference was not granted; authorize plan usage."
|
|
333
|
+
)
|
|
334
|
+
record = {**self._token_record(tokens), **identity}
|
|
335
|
+
with self.store.lock():
|
|
336
|
+
if self._record() != (previous or {}):
|
|
337
|
+
raise AuthenticationError("Profile changed during login; start login again.")
|
|
338
|
+
self._save(record)
|
|
339
|
+
|
|
340
|
+
def access_token(self) -> str:
|
|
341
|
+
# Re-read AFTER acquiring lock: a second process sees the first process's rotated tokens.
|
|
342
|
+
with self.store.lock():
|
|
343
|
+
record = self._record()
|
|
344
|
+
if record.get("id_token") and not PLAN_SCOPES.issubset(record.get("scopes", [])):
|
|
345
|
+
raise PermissionDenied(
|
|
346
|
+
"Identity is signed in, but ChatGPT plan inference is disabled."
|
|
347
|
+
)
|
|
348
|
+
if not record.get("access_token") or record.get("needs_login"):
|
|
349
|
+
raise AuthenticationError("Human authentication required: run rosa-llm login.")
|
|
350
|
+
if not PLAN_SCOPES.issubset(record.get("scopes", [])):
|
|
351
|
+
raise PermissionDenied("This login has no ChatGPT plan inference permission.")
|
|
352
|
+
self._validate_pending_identity(record)
|
|
353
|
+
if record.get("expires_at", 0) > time.time() + 30:
|
|
354
|
+
return record["access_token"]
|
|
355
|
+
if not record.get("refresh_token"):
|
|
356
|
+
raise AuthenticationError("Session cannot be renewed; run rosa-llm login.")
|
|
357
|
+
try:
|
|
358
|
+
tokens = self._tokens(
|
|
359
|
+
{
|
|
360
|
+
"grant_type": "refresh_token",
|
|
361
|
+
"client_id": record["client_id"],
|
|
362
|
+
"refresh_token": record["refresh_token"],
|
|
363
|
+
"resource": RESOURCE,
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
except AuthenticationError:
|
|
367
|
+
self._invalidate(record)
|
|
368
|
+
raise
|
|
369
|
+
try:
|
|
370
|
+
updated = self._token_record(tokens, record)
|
|
371
|
+
except (AuthenticationError, PermissionDenied):
|
|
372
|
+
# The old refresh token may already be consumed; never keep retrying it.
|
|
373
|
+
self._invalidate(record)
|
|
374
|
+
raise
|
|
375
|
+
if tokens.get("id_token"):
|
|
376
|
+
updated["pending_id_token"] = tokens["id_token"]
|
|
377
|
+
# Persist rotation before fetching validation keys: a network failure must not
|
|
378
|
+
# lose the replacement refresh token or retry its already-consumed predecessor.
|
|
379
|
+
self._save(updated)
|
|
380
|
+
self._validate_pending_identity(updated)
|
|
381
|
+
return updated["access_token"]
|
|
382
|
+
|
|
383
|
+
def logout(self) -> dict:
|
|
384
|
+
confirmed = True
|
|
385
|
+
with self.store.lock():
|
|
386
|
+
record = self._record()
|
|
387
|
+
if record.get("refresh_token"):
|
|
388
|
+
confirmed = False
|
|
389
|
+
try:
|
|
390
|
+
endpoint = self._discovery()["revocation_endpoint"]
|
|
391
|
+
for attempt in range(3):
|
|
392
|
+
try:
|
|
393
|
+
response = self.http.post(
|
|
394
|
+
endpoint,
|
|
395
|
+
follow_redirects=False,
|
|
396
|
+
data={
|
|
397
|
+
"token": record["refresh_token"],
|
|
398
|
+
"token_type_hint": "refresh_token",
|
|
399
|
+
"client_id": record["client_id"],
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
if response.status_code == 200:
|
|
403
|
+
confirmed = True
|
|
404
|
+
break
|
|
405
|
+
if response.status_code < 500:
|
|
406
|
+
break
|
|
407
|
+
except httpx.TransportError:
|
|
408
|
+
pass
|
|
409
|
+
if attempt < 2:
|
|
410
|
+
time.sleep(0.2 * 2**attempt)
|
|
411
|
+
except (httpx.HTTPError, KeyError, LLMError):
|
|
412
|
+
pass
|
|
413
|
+
for key in ("access_token", "refresh_token", "id_token", "pending_id_token"):
|
|
414
|
+
record.pop(key, None)
|
|
415
|
+
if record:
|
|
416
|
+
self._save(record)
|
|
417
|
+
return {
|
|
418
|
+
"profile": self.profile,
|
|
419
|
+
"authenticated": False,
|
|
420
|
+
"remote_revocation_confirmed": confirmed,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
def login(
|
|
424
|
+
self,
|
|
425
|
+
*,
|
|
426
|
+
port: int = 1455,
|
|
427
|
+
open_browser: bool = True,
|
|
428
|
+
consent: bool = False,
|
|
429
|
+
announce: Callable[[str], None] = print,
|
|
430
|
+
timeout: float = 180,
|
|
431
|
+
):
|
|
432
|
+
"""Loopback only. Docker uses host networking, never a public callback bind."""
|
|
433
|
+
host_id = self.store.host_id()
|
|
434
|
+
with self.store.lock():
|
|
435
|
+
previous = self._record()
|
|
436
|
+
manager = self
|
|
437
|
+
result: dict = {}
|
|
438
|
+
|
|
439
|
+
class CallbackHandler(BaseHTTPRequestHandler):
|
|
440
|
+
def log_message(self, format, *args):
|
|
441
|
+
pass # BaseHTTPRequestHandler would otherwise log callback secrets.
|
|
442
|
+
|
|
443
|
+
def do_GET(self):
|
|
444
|
+
parsed = urlsplit(self.path)
|
|
445
|
+
if parsed.path != "/auth/callback":
|
|
446
|
+
self.send_error(404)
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
manager.finish_login(pending, parsed.query, previous)
|
|
450
|
+
result["ok"] = True
|
|
451
|
+
body = b"Sign-in complete. You can close this tab."
|
|
452
|
+
except Exception as error:
|
|
453
|
+
result["error"] = error
|
|
454
|
+
body = b"Sign-in failed. Check the terminal for a safe error description."
|
|
455
|
+
self.send_response(200 if result.get("ok") else 400)
|
|
456
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
457
|
+
self.send_header("Cache-Control", "no-store")
|
|
458
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
459
|
+
self.send_header("Content-Length", str(len(body)))
|
|
460
|
+
self.end_headers()
|
|
461
|
+
self.wfile.write(body)
|
|
462
|
+
|
|
463
|
+
class LocalServer(HTTPServer):
|
|
464
|
+
def get_request(self):
|
|
465
|
+
connection, address = super().get_request()
|
|
466
|
+
connection.settimeout(3)
|
|
467
|
+
return connection, address
|
|
468
|
+
|
|
469
|
+
def handle_error(self, request, client_address):
|
|
470
|
+
pass # No tracebacks with request/token details.
|
|
471
|
+
|
|
472
|
+
with LocalServer(("127.0.0.1", port), CallbackHandler) as server:
|
|
473
|
+
server.timeout = 0.5
|
|
474
|
+
pending = PendingLogin(
|
|
475
|
+
previous.get("client_id", "dynamic_agent_client"),
|
|
476
|
+
f"http://127.0.0.1:{server.server_port}/auth/callback",
|
|
477
|
+
host_id,
|
|
478
|
+
)
|
|
479
|
+
opened = False
|
|
480
|
+
if open_browser:
|
|
481
|
+
try:
|
|
482
|
+
opened = webbrowser.open(pending.url(previous, hints=True, consent=consent))
|
|
483
|
+
except webbrowser.Error:
|
|
484
|
+
pass
|
|
485
|
+
if not opened:
|
|
486
|
+
announce("Open this authorization URL in your browser (keep it private):")
|
|
487
|
+
announce(pending.url(consent=consent)) # No saved identity hints in output.
|
|
488
|
+
announce("Waiting for local callback; complete authorization in the browser.")
|
|
489
|
+
deadline = time.monotonic() + timeout
|
|
490
|
+
while not result and time.monotonic() < deadline:
|
|
491
|
+
server.handle_request()
|
|
492
|
+
if "error" in result:
|
|
493
|
+
raise result["error"]
|
|
494
|
+
if not result.get("ok"):
|
|
495
|
+
raise AuthenticationError(
|
|
496
|
+
"Login timed out; check loopback forwarding and try again."
|
|
497
|
+
)
|