edapitool 0.6.3__tar.gz
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.
- edapitool-0.6.3/APITool/__init__.py +20 -0
- edapitool-0.6.3/APITool/auth.py +490 -0
- edapitool-0.6.3/APITool/capi.py +292 -0
- edapitool-0.6.3/APITool/cargo.py +148 -0
- edapitool-0.6.3/APITool/catalog.py +274 -0
- edapitool-0.6.3/APITool/cli.py +1419 -0
- edapitool-0.6.3/APITool/constants.py +36 -0
- edapitool-0.6.3/APITool/construction.py +451 -0
- edapitool-0.6.3/APITool/daemon.py +668 -0
- edapitool-0.6.3/APITool/data/fdev_commodities.csv +413 -0
- edapitool-0.6.3/APITool/data/name_aliases.json +10 -0
- edapitool-0.6.3/APITool/export.py +987 -0
- edapitool-0.6.3/APITool/google/__init__.py +10 -0
- edapitool-0.6.3/APITool/google/exporter.py +517 -0
- edapitool-0.6.3/APITool/journal.py +429 -0
- edapitool-0.6.3/APITool/market.py +463 -0
- edapitool-0.6.3/APITool/matcher.py +279 -0
- edapitool-0.6.3/APITool/models.py +491 -0
- edapitool-0.6.3/APITool/service.py +398 -0
- edapitool-0.6.3/APITool/settings.py +149 -0
- edapitool-0.6.3/APITool/sheets/__init__.py +41 -0
- edapitool-0.6.3/APITool/sheets/a1.py +107 -0
- edapitool-0.6.3/APITool/sheets/destination.py +185 -0
- edapitool-0.6.3/APITool/sheets/guard.py +77 -0
- edapitool-0.6.3/APITool/sheets/layout.py +122 -0
- edapitool-0.6.3/APITool/ship.py +315 -0
- edapitool-0.6.3/APITool/version.py +130 -0
- edapitool-0.6.3/APITool/workbook/__init__.py +12 -0
- edapitool-0.6.3/APITool/workbook/markers.py +440 -0
- edapitool-0.6.3/APITool/workbook/totals.py +338 -0
- edapitool-0.6.3/LICENSE +232 -0
- edapitool-0.6.3/PKG-INFO +230 -0
- edapitool-0.6.3/README.md +186 -0
- edapitool-0.6.3/edapitool.egg-info/PKG-INFO +230 -0
- edapitool-0.6.3/edapitool.egg-info/SOURCES.txt +59 -0
- edapitool-0.6.3/edapitool.egg-info/dependency_links.txt +1 -0
- edapitool-0.6.3/edapitool.egg-info/entry_points.txt +3 -0
- edapitool-0.6.3/edapitool.egg-info/requires.txt +19 -0
- edapitool-0.6.3/edapitool.egg-info/top_level.txt +1 -0
- edapitool-0.6.3/pyproject.toml +133 -0
- edapitool-0.6.3/setup.cfg +4 -0
- edapitool-0.6.3/tests/test_baseline_freshness.py +97 -0
- edapitool-0.6.3/tests/test_capi_cooldown.py +88 -0
- edapitool-0.6.3/tests/test_cargo_contract.py +256 -0
- edapitool-0.6.3/tests/test_catalog.py +429 -0
- edapitool-0.6.3/tests/test_ci_workflow.py +135 -0
- edapitool-0.6.3/tests/test_cli_ship.py +217 -0
- edapitool-0.6.3/tests/test_config_regions.py +253 -0
- edapitool-0.6.3/tests/test_construction.py +305 -0
- edapitool-0.6.3/tests/test_daemon.py +658 -0
- edapitool-0.6.3/tests/test_destination.py +188 -0
- edapitool-0.6.3/tests/test_gsheet_guard.py +110 -0
- edapitool-0.6.3/tests/test_journal.py +483 -0
- edapitool-0.6.3/tests/test_marker_formula.py +173 -0
- edapitool-0.6.3/tests/test_market_data.py +577 -0
- edapitool-0.6.3/tests/test_matcher.py +457 -0
- edapitool-0.6.3/tests/test_region_publishing.py +386 -0
- edapitool-0.6.3/tests/test_service.py +691 -0
- edapitool-0.6.3/tests/test_settings.py +183 -0
- edapitool-0.6.3/tests/test_sheets.py +949 -0
- edapitool-0.6.3/tests/test_ship.py +246 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""
|
|
2
|
+
APITool - Elite Dangerous API Tool
|
|
3
|
+
|
|
4
|
+
A Python tool for accessing the Elite Dangerous Companion API (CAPI)
|
|
5
|
+
to extract fleet carrier inventory and other game data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .version import get_base_version
|
|
9
|
+
|
|
10
|
+
# Derived, never hardcoded: this line said "0.1.0" through three releases
|
|
11
|
+
# because nothing kept it in step with version.py, which the build, the CLI
|
|
12
|
+
# and the release tooling all read instead.
|
|
13
|
+
__version__ = get_base_version()
|
|
14
|
+
__author__ = "djdarcy"
|
|
15
|
+
|
|
16
|
+
from .capi import CAPIClient
|
|
17
|
+
from .auth import FrontierAuth
|
|
18
|
+
from .models import FleetCarrier, CargoItem
|
|
19
|
+
|
|
20
|
+
__all__ = ["CAPIClient", "FrontierAuth", "FleetCarrier", "CargoItem", "__version__"]
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth2 authentication handler for Frontier's CAPI service.
|
|
3
|
+
|
|
4
|
+
To use the CAPI, you need to:
|
|
5
|
+
1. Register your application at https://auth.frontierstore.net/client/signup
|
|
6
|
+
2. Obtain a client_id
|
|
7
|
+
3. Use this module to authenticate users via OAuth2
|
|
8
|
+
|
|
9
|
+
Note: Tokens expire and need refresh every ~25 days.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import secrets
|
|
14
|
+
import webbrowser
|
|
15
|
+
import hashlib
|
|
16
|
+
import base64
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from datetime import datetime, timedelta
|
|
20
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
21
|
+
from typing import Optional
|
|
22
|
+
import threading
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from .constants import (
|
|
27
|
+
AUTH_SERVER,
|
|
28
|
+
AUTH_PATH_AUTH,
|
|
29
|
+
AUTH_PATH_TOKEN,
|
|
30
|
+
TOKEN_FILE,
|
|
31
|
+
OAUTH_SCOPES,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TokenStorage:
|
|
36
|
+
"""Handles secure storage and retrieval of OAuth tokens."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, token_file: Optional[Path] = None):
|
|
39
|
+
self.token_file = token_file or Path.home() / TOKEN_FILE
|
|
40
|
+
|
|
41
|
+
def save(self, tokens: dict) -> None:
|
|
42
|
+
"""Save tokens to file."""
|
|
43
|
+
tokens["saved_at"] = datetime.now().isoformat()
|
|
44
|
+
self.token_file.write_text(json.dumps(tokens, indent=2))
|
|
45
|
+
# Set restrictive permissions on Unix systems
|
|
46
|
+
try:
|
|
47
|
+
self.token_file.chmod(0o600)
|
|
48
|
+
except (OSError, AttributeError):
|
|
49
|
+
pass # Windows doesn't support chmod the same way
|
|
50
|
+
|
|
51
|
+
def load(self) -> Optional[dict]:
|
|
52
|
+
"""Load tokens from file if they exist."""
|
|
53
|
+
if not self.token_file.exists():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(self.token_file.read_text())
|
|
57
|
+
except (json.JSONDecodeError, IOError):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
"""Remove stored tokens."""
|
|
62
|
+
if self.token_file.exists():
|
|
63
|
+
self.token_file.unlink()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
67
|
+
"""HTTP handler for OAuth2 callback."""
|
|
68
|
+
|
|
69
|
+
def log_message(self, format, *args):
|
|
70
|
+
"""Suppress HTTP server logs."""
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def do_GET(self):
|
|
74
|
+
"""Handle the OAuth callback."""
|
|
75
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
76
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
77
|
+
|
|
78
|
+
if "code" in query:
|
|
79
|
+
self.server.auth_code = query["code"][0]
|
|
80
|
+
self.server.auth_state = query.get("state", [None])[0]
|
|
81
|
+
response = b"<html><body><h1>Authorization successful!</h1><p>You can close this window.</p></body></html>"
|
|
82
|
+
self.send_response(200)
|
|
83
|
+
else:
|
|
84
|
+
self.server.auth_code = None
|
|
85
|
+
self.server.auth_error = query.get("error", ["unknown"])[0]
|
|
86
|
+
response = b"<html><body><h1>Authorization failed</h1><p>Please try again.</p></body></html>"
|
|
87
|
+
self.send_response(400)
|
|
88
|
+
|
|
89
|
+
self.send_header("Content-type", "text/html")
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(response)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FrontierAuth:
|
|
95
|
+
"""
|
|
96
|
+
Handles OAuth2 authentication with Frontier's auth service.
|
|
97
|
+
|
|
98
|
+
Uses PKCE (Proof Key for Code Exchange) for security.
|
|
99
|
+
|
|
100
|
+
Supports two authorization modes:
|
|
101
|
+
- Automatic: Local HTTP server captures callback (requires localhost redirect)
|
|
102
|
+
- Manual: User copies authorization code from browser URL
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
# Default redirect URI for manual auth flow (doesn't need to be a real server)
|
|
106
|
+
DEFAULT_REDIRECT_URI = "https://localhost/callback"
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
client_id: str,
|
|
111
|
+
redirect_uri: Optional[str] = None,
|
|
112
|
+
redirect_port: int = 8085,
|
|
113
|
+
token_storage: Optional[TokenStorage] = None,
|
|
114
|
+
):
|
|
115
|
+
"""
|
|
116
|
+
Initialize the auth handler.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
client_id: Your registered application's client ID
|
|
120
|
+
redirect_uri: Custom redirect URI (for manual flow or custom domains)
|
|
121
|
+
redirect_port: Local port for OAuth callback (default 8085, ignored if redirect_uri set)
|
|
122
|
+
token_storage: Optional custom token storage handler
|
|
123
|
+
"""
|
|
124
|
+
self.client_id = client_id
|
|
125
|
+
self.redirect_port = redirect_port
|
|
126
|
+
self.storage = token_storage or TokenStorage()
|
|
127
|
+
|
|
128
|
+
# Use custom redirect_uri if provided, otherwise default to localhost
|
|
129
|
+
if redirect_uri:
|
|
130
|
+
self.redirect_uri = redirect_uri
|
|
131
|
+
self._use_local_server = False
|
|
132
|
+
else:
|
|
133
|
+
self.redirect_uri = f"http://localhost:{redirect_port}/callback"
|
|
134
|
+
self._use_local_server = True
|
|
135
|
+
|
|
136
|
+
self._access_token: Optional[str] = None
|
|
137
|
+
self._refresh_token: Optional[str] = None
|
|
138
|
+
self._token_expiry: Optional[datetime] = None
|
|
139
|
+
|
|
140
|
+
# Store PKCE verifier for manual flow
|
|
141
|
+
self._pending_verifier: Optional[str] = None
|
|
142
|
+
self._pending_state: Optional[str] = None
|
|
143
|
+
|
|
144
|
+
# Try to load existing tokens
|
|
145
|
+
self._load_tokens()
|
|
146
|
+
|
|
147
|
+
def _load_tokens(self) -> bool:
|
|
148
|
+
"""Load tokens from storage."""
|
|
149
|
+
tokens = self.storage.load()
|
|
150
|
+
if tokens:
|
|
151
|
+
self._access_token = tokens.get("access_token")
|
|
152
|
+
self._refresh_token = tokens.get("refresh_token")
|
|
153
|
+
if "expires_at" in tokens:
|
|
154
|
+
self._token_expiry = datetime.fromisoformat(tokens["expires_at"])
|
|
155
|
+
return True
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def _save_tokens(self) -> None:
|
|
159
|
+
"""Save current tokens to storage."""
|
|
160
|
+
if self._access_token:
|
|
161
|
+
tokens = {
|
|
162
|
+
"access_token": self._access_token,
|
|
163
|
+
"refresh_token": self._refresh_token,
|
|
164
|
+
"expires_at": self._token_expiry.isoformat()
|
|
165
|
+
if self._token_expiry
|
|
166
|
+
else None,
|
|
167
|
+
}
|
|
168
|
+
self.storage.save(tokens)
|
|
169
|
+
|
|
170
|
+
def _generate_pkce(self) -> tuple[str, str]:
|
|
171
|
+
"""Generate PKCE code verifier and challenge."""
|
|
172
|
+
# Generate random verifier (43-128 chars)
|
|
173
|
+
verifier = secrets.token_urlsafe(32)
|
|
174
|
+
|
|
175
|
+
# Create SHA256 hash, then base64url encode
|
|
176
|
+
digest = hashlib.sha256(verifier.encode()).digest()
|
|
177
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
178
|
+
|
|
179
|
+
return verifier, challenge
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def is_authenticated(self) -> bool:
|
|
183
|
+
"""
|
|
184
|
+
Do we hold usable authorization?
|
|
185
|
+
|
|
186
|
+
An EXPIRED access token is not the same as no authorization. Frontier
|
|
187
|
+
access tokens last about four hours while the refresh token is good for
|
|
188
|
+
weeks, so treating expiry as "not authenticated" sends the commander
|
|
189
|
+
through a full browser consent flow several times a day -- and makes a
|
|
190
|
+
long-running daemon impossible, since nobody is at the keyboard to
|
|
191
|
+
click through it. Spend the refresh token first.
|
|
192
|
+
"""
|
|
193
|
+
if not self._access_token:
|
|
194
|
+
return False
|
|
195
|
+
if self._token_expiry and datetime.now() >= self._token_expiry:
|
|
196
|
+
if self._refresh_token:
|
|
197
|
+
return self.refresh()
|
|
198
|
+
return False
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def access_token(self) -> Optional[str]:
|
|
203
|
+
"""Get the current access token, refreshing if needed."""
|
|
204
|
+
if not self._access_token:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
# Check if token is expired or about to expire (5 min buffer)
|
|
208
|
+
if self._token_expiry:
|
|
209
|
+
if datetime.now() >= self._token_expiry - timedelta(minutes=5):
|
|
210
|
+
if self._refresh_token:
|
|
211
|
+
self.refresh()
|
|
212
|
+
|
|
213
|
+
return self._access_token
|
|
214
|
+
|
|
215
|
+
def authorize(self, timeout: int = 120, manual: bool = False) -> bool:
|
|
216
|
+
"""
|
|
217
|
+
Initiate OAuth2 authorization flow.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
timeout: Seconds to wait for user authorization (auto mode only)
|
|
221
|
+
manual: If True, use manual code entry instead of local server
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
True if authorization successful
|
|
225
|
+
"""
|
|
226
|
+
# Force manual mode if not using local server
|
|
227
|
+
if not self._use_local_server:
|
|
228
|
+
manual = True
|
|
229
|
+
|
|
230
|
+
if manual:
|
|
231
|
+
return self.authorize_manual()
|
|
232
|
+
else:
|
|
233
|
+
return self.authorize_auto(timeout)
|
|
234
|
+
|
|
235
|
+
def authorize_auto(self, timeout: int = 120) -> bool:
|
|
236
|
+
"""
|
|
237
|
+
Automatic authorization using local HTTP server.
|
|
238
|
+
|
|
239
|
+
Opens browser for user to authorize, then captures callback.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
timeout: Seconds to wait for user authorization
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
True if authorization successful
|
|
246
|
+
"""
|
|
247
|
+
# Generate PKCE values
|
|
248
|
+
verifier, challenge = self._generate_pkce()
|
|
249
|
+
state = secrets.token_urlsafe(16)
|
|
250
|
+
|
|
251
|
+
# Build authorization URL
|
|
252
|
+
params = {
|
|
253
|
+
"response_type": "code",
|
|
254
|
+
"client_id": self.client_id,
|
|
255
|
+
"redirect_uri": self.redirect_uri,
|
|
256
|
+
"scope": " ".join(OAUTH_SCOPES),
|
|
257
|
+
"state": state,
|
|
258
|
+
"code_challenge": challenge,
|
|
259
|
+
"code_challenge_method": "S256",
|
|
260
|
+
}
|
|
261
|
+
auth_url = f"{AUTH_SERVER}{AUTH_PATH_AUTH}?{urllib.parse.urlencode(params)}"
|
|
262
|
+
|
|
263
|
+
# Start local server to receive callback
|
|
264
|
+
try:
|
|
265
|
+
server = HTTPServer(("localhost", self.redirect_port), OAuthCallbackHandler)
|
|
266
|
+
except OSError as e:
|
|
267
|
+
print(f"Could not start local server on port {self.redirect_port}: {e}")
|
|
268
|
+
print("Falling back to manual authorization...")
|
|
269
|
+
return self.authorize_manual()
|
|
270
|
+
|
|
271
|
+
server.auth_code = None
|
|
272
|
+
server.auth_error = None
|
|
273
|
+
server.auth_state = None
|
|
274
|
+
server.timeout = timeout
|
|
275
|
+
|
|
276
|
+
# Open browser
|
|
277
|
+
print(f"Opening browser for authorization...")
|
|
278
|
+
print(f"If browser doesn't open, visit: {auth_url}")
|
|
279
|
+
webbrowser.open(auth_url)
|
|
280
|
+
|
|
281
|
+
# Wait for callback (with timeout)
|
|
282
|
+
server.handle_request()
|
|
283
|
+
server.server_close()
|
|
284
|
+
|
|
285
|
+
if not server.auth_code:
|
|
286
|
+
print(f"Authorization failed: {server.auth_error}")
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
if server.auth_state != state:
|
|
290
|
+
print("Authorization failed: state mismatch (possible CSRF attack)")
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
# Exchange code for tokens
|
|
294
|
+
return self._exchange_code(server.auth_code, verifier)
|
|
295
|
+
|
|
296
|
+
def authorize_manual(self) -> bool:
|
|
297
|
+
"""
|
|
298
|
+
Manual authorization flow - user copies code from browser URL.
|
|
299
|
+
|
|
300
|
+
This flow works even if the redirect URI doesn't point to a real server.
|
|
301
|
+
The user will see a "page not found" error but can copy the code from the URL.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if authorization successful
|
|
305
|
+
"""
|
|
306
|
+
# Generate PKCE values
|
|
307
|
+
verifier, challenge = self._generate_pkce()
|
|
308
|
+
state = secrets.token_urlsafe(16)
|
|
309
|
+
|
|
310
|
+
# Store for later use
|
|
311
|
+
self._pending_verifier = verifier
|
|
312
|
+
self._pending_state = state
|
|
313
|
+
|
|
314
|
+
# Build authorization URL
|
|
315
|
+
params = {
|
|
316
|
+
"response_type": "code",
|
|
317
|
+
"client_id": self.client_id,
|
|
318
|
+
"redirect_uri": self.redirect_uri,
|
|
319
|
+
"scope": " ".join(OAUTH_SCOPES),
|
|
320
|
+
"state": state,
|
|
321
|
+
"code_challenge": challenge,
|
|
322
|
+
"code_challenge_method": "S256",
|
|
323
|
+
}
|
|
324
|
+
auth_url = f"{AUTH_SERVER}{AUTH_PATH_AUTH}?{urllib.parse.urlencode(params)}"
|
|
325
|
+
|
|
326
|
+
print()
|
|
327
|
+
print("=" * 60)
|
|
328
|
+
print("MANUAL AUTHORIZATION FLOW")
|
|
329
|
+
print("=" * 60)
|
|
330
|
+
print()
|
|
331
|
+
print("1. Open this URL in your browser:")
|
|
332
|
+
print()
|
|
333
|
+
print(f" {auth_url}")
|
|
334
|
+
print()
|
|
335
|
+
print("2. Log in to your Frontier account and authorize the app")
|
|
336
|
+
print()
|
|
337
|
+
print("3. You'll be redirected to a page that may show an error")
|
|
338
|
+
print(" (This is expected if the redirect URL isn't a real server)")
|
|
339
|
+
print()
|
|
340
|
+
print("4. Copy the ENTIRE URL from your browser's address bar")
|
|
341
|
+
print(" It will look like: https://...?code=XXXXX&state=YYYYY")
|
|
342
|
+
print()
|
|
343
|
+
print("=" * 60)
|
|
344
|
+
print()
|
|
345
|
+
|
|
346
|
+
# Try to open browser
|
|
347
|
+
try:
|
|
348
|
+
webbrowser.open(auth_url)
|
|
349
|
+
print("(Browser should have opened automatically)")
|
|
350
|
+
print()
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
|
|
354
|
+
# Get the callback URL from user
|
|
355
|
+
callback_url = input("Paste the full callback URL here: ").strip()
|
|
356
|
+
|
|
357
|
+
if not callback_url:
|
|
358
|
+
print("No URL provided.")
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
return self.complete_manual_auth(callback_url)
|
|
362
|
+
|
|
363
|
+
def complete_manual_auth(self, callback_url: str) -> bool:
|
|
364
|
+
"""
|
|
365
|
+
Complete manual authorization by parsing the callback URL.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
callback_url: The full URL from the browser after authorization
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
True if authorization successful
|
|
372
|
+
"""
|
|
373
|
+
if not self._pending_verifier:
|
|
374
|
+
print("No pending authorization. Call authorize_manual() first.")
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
# Parse the callback URL
|
|
378
|
+
try:
|
|
379
|
+
parsed = urllib.parse.urlparse(callback_url)
|
|
380
|
+
query = urllib.parse.parse_qs(parsed.query)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
print(f"Could not parse URL: {e}")
|
|
383
|
+
return False
|
|
384
|
+
|
|
385
|
+
# Check for error
|
|
386
|
+
if "error" in query:
|
|
387
|
+
error = query["error"][0]
|
|
388
|
+
error_desc = query.get("error_description", [""])[0]
|
|
389
|
+
print(f"Authorization failed: {error}")
|
|
390
|
+
if error_desc:
|
|
391
|
+
print(f" {error_desc}")
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
# Get the code
|
|
395
|
+
if "code" not in query:
|
|
396
|
+
print("No authorization code found in URL.")
|
|
397
|
+
print("Make sure you copied the complete URL including the ?code=... part")
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
code = query["code"][0]
|
|
401
|
+
|
|
402
|
+
# Verify state if present
|
|
403
|
+
if "state" in query:
|
|
404
|
+
if query["state"][0] != self._pending_state:
|
|
405
|
+
print("Warning: State mismatch. This could indicate a security issue.")
|
|
406
|
+
# Continue anyway for usability, but warn the user
|
|
407
|
+
|
|
408
|
+
# Exchange code for tokens
|
|
409
|
+
verifier = self._pending_verifier
|
|
410
|
+
self._pending_verifier = None
|
|
411
|
+
self._pending_state = None
|
|
412
|
+
|
|
413
|
+
return self._exchange_code(code, verifier)
|
|
414
|
+
|
|
415
|
+
def _exchange_code(self, code: str, verifier: str) -> bool:
|
|
416
|
+
"""Exchange authorization code for tokens."""
|
|
417
|
+
data = {
|
|
418
|
+
"grant_type": "authorization_code",
|
|
419
|
+
"code": code,
|
|
420
|
+
"redirect_uri": self.redirect_uri,
|
|
421
|
+
"client_id": self.client_id,
|
|
422
|
+
"code_verifier": verifier,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
response = requests.post(
|
|
427
|
+
f"{AUTH_SERVER}{AUTH_PATH_TOKEN}",
|
|
428
|
+
data=data,
|
|
429
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
430
|
+
timeout=30,
|
|
431
|
+
)
|
|
432
|
+
response.raise_for_status()
|
|
433
|
+
|
|
434
|
+
tokens = response.json()
|
|
435
|
+
self._access_token = tokens["access_token"]
|
|
436
|
+
self._refresh_token = tokens.get("refresh_token")
|
|
437
|
+
|
|
438
|
+
# Calculate expiry time
|
|
439
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
440
|
+
self._token_expiry = datetime.now() + timedelta(seconds=expires_in)
|
|
441
|
+
|
|
442
|
+
self._save_tokens()
|
|
443
|
+
print("Authorization successful!")
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
except requests.RequestException as e:
|
|
447
|
+
print(f"Token exchange failed: {e}")
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def refresh(self) -> bool:
|
|
451
|
+
"""Refresh the access token using the refresh token."""
|
|
452
|
+
if not self._refresh_token:
|
|
453
|
+
print("No refresh token available")
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
data = {
|
|
457
|
+
"grant_type": "refresh_token",
|
|
458
|
+
"refresh_token": self._refresh_token,
|
|
459
|
+
"client_id": self.client_id,
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
response = requests.post(
|
|
464
|
+
f"{AUTH_SERVER}{AUTH_PATH_TOKEN}",
|
|
465
|
+
data=data,
|
|
466
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
467
|
+
timeout=30,
|
|
468
|
+
)
|
|
469
|
+
response.raise_for_status()
|
|
470
|
+
|
|
471
|
+
tokens = response.json()
|
|
472
|
+
self._access_token = tokens["access_token"]
|
|
473
|
+
self._refresh_token = tokens.get("refresh_token", self._refresh_token)
|
|
474
|
+
|
|
475
|
+
expires_in = tokens.get("expires_in", 3600)
|
|
476
|
+
self._token_expiry = datetime.now() + timedelta(seconds=expires_in)
|
|
477
|
+
|
|
478
|
+
self._save_tokens()
|
|
479
|
+
return True
|
|
480
|
+
|
|
481
|
+
except requests.RequestException as e:
|
|
482
|
+
print(f"Token refresh failed: {e}")
|
|
483
|
+
return False
|
|
484
|
+
|
|
485
|
+
def logout(self) -> None:
|
|
486
|
+
"""Clear stored tokens."""
|
|
487
|
+
self._access_token = None
|
|
488
|
+
self._refresh_token = None
|
|
489
|
+
self._token_expiry = None
|
|
490
|
+
self.storage.clear()
|