redfetch 1.3.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.
redfetch/auth.py ADDED
@@ -0,0 +1,573 @@
1
+ """Can be used as a standalone script to authorize with RedGuides.
2
+
3
+ redfetch supports two auth modes:
4
+ - API key (via REDGUIDES_API_KEY env var)
5
+ - XenForo 2.3 native OAuth2
6
+ """
7
+
8
+ # standard
9
+ import base64
10
+ import hashlib
11
+ import asyncio
12
+ import os
13
+ import time
14
+ import webbrowser
15
+ from datetime import datetime, timedelta
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
+ from typing import Optional
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+
20
+ # third-party
21
+ import httpx
22
+ import keyring # for storing tokens (secrets only)
23
+ from diskcache import Cache
24
+ from keyring.errors import NoKeyringError
25
+
26
+ # Local
27
+ from redfetch import config
28
+ from redfetch import net
29
+
30
+ # Constants
31
+ KEYRING_SERVICE_NAME = "redfetch"
32
+ BASE_URL = net.BASE_URL
33
+
34
+ AUTHORIZATION_ENDPOINT = f"{BASE_URL}/oauth2/authorize"
35
+ TOKEN_ENDPOINT = f"{BASE_URL}/api/oauth2/token"
36
+
37
+ # Loopback redirect default (must match the OAuth client redirect URI exactly)
38
+ DEFAULT_REDIRECT_URI = "http://127.0.0.1:62897/"
39
+ DEFAULT_LOOPBACK_PORT = 62897
40
+ _REFRESH_CODE_VERIFIER = "refresh" # XF 2.3 requires a non-empty code_verifier even for refresh (public clients)
41
+
42
+
43
+ def _get_setting(key: str, default=None):
44
+ """Get a setting from env first, then initialized Dynaconf settings."""
45
+ env_key = f"REDFETCH_{key}"
46
+ env_val = os.environ.get(env_key)
47
+ if env_val not in (None, ""):
48
+ return env_val
49
+
50
+ if config.settings is not None:
51
+ val = config.settings.get(key, default)
52
+ return default if val in ("", None) else val
53
+
54
+ return default
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Disk cache for non-secret identity data (user_id, username, token_expiry)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _disk_cache = None
62
+
63
+
64
+ def _get_disk_cache_instance():
65
+ """Create a diskcache.Cache in the config directory."""
66
+ cache_dir = getattr(config, 'config_dir', None) or os.getenv('REDFETCH_CONFIG_DIR')
67
+ if not cache_dir:
68
+ cache_dir = os.getcwd()
69
+ return Cache(os.path.join(cache_dir, '.cache'))
70
+
71
+
72
+ def _ensure_cache():
73
+ global _disk_cache
74
+ if _disk_cache is None:
75
+ _disk_cache = _get_disk_cache_instance()
76
+ return _disk_cache
77
+
78
+
79
+ def get_disk_cache():
80
+ """Return the shared disk cache (for non-identity caching by other modules)."""
81
+ return _ensure_cache()
82
+
83
+
84
+ def set_user_id(user_id: str) -> None:
85
+ """Store user_id in disk cache (non-sensitive public identifier)."""
86
+ _ensure_cache().set('user_id', str(user_id))
87
+
88
+
89
+ def get_user_id() -> Optional[str]:
90
+ """Retrieve user_id from disk cache."""
91
+ return _ensure_cache().get('user_id')
92
+
93
+
94
+ def set_username(username: str) -> None:
95
+ """Store username in disk cache (non-sensitive public display name)."""
96
+ _ensure_cache().set('username', username)
97
+
98
+
99
+ def get_username_from_cache() -> Optional[str]:
100
+ """Retrieve username from disk cache."""
101
+ return _ensure_cache().get('username')
102
+
103
+
104
+ def set_token_expiry(expires_at: str) -> None:
105
+ """Store OAuth token expiry timestamp in disk cache."""
106
+ _ensure_cache().set('expires_at', expires_at)
107
+
108
+
109
+ def get_token_expiry() -> Optional[str]:
110
+ """Retrieve OAuth token expiry timestamp from disk cache."""
111
+ return _ensure_cache().get('expires_at')
112
+
113
+
114
+ def clear_disk_cache():
115
+ """Clear all cached non-secret data."""
116
+ global _disk_cache
117
+ cache = _ensure_cache()
118
+ try:
119
+ cache.clear()
120
+ finally:
121
+ try:
122
+ cache.close()
123
+ except Exception:
124
+ pass
125
+ _disk_cache = None
126
+
127
+
128
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
129
+ """Capture XF OAuth2 redirect responses for loopback redirects."""
130
+
131
+ def log_message(self, format, *args): # noqa: A002 (shadowing built-in 'format')
132
+ # Silence noisy default logging.
133
+ return
134
+
135
+ def do_GET(self):
136
+ query = parse_qs(urlparse(self.path).query)
137
+
138
+ error = (query.get("error") or [None])[0]
139
+ error_description = (query.get("error_description") or [None])[0]
140
+ code = (query.get("code") or [None])[0]
141
+ state = (query.get("state") or [None])[0]
142
+
143
+ # Some browsers will request /favicon.ico or similar first; ignore those.
144
+ if not error and (not code or not state):
145
+ self.send_response(404)
146
+ self.send_header("Content-type", "text/plain; charset=utf-8")
147
+ self.end_headers()
148
+ self.wfile.write(b"Waiting for OAuth response...")
149
+ return
150
+
151
+ if error:
152
+ self.server.error = f"{error} {error_description or ''}".strip()
153
+ self.send_response(200)
154
+ self.send_header("Content-type", "text/plain; charset=utf-8")
155
+ self.end_headers()
156
+ self.wfile.write(b"Authorization failed. You can close this tab.")
157
+ return
158
+
159
+ self.server.code = code
160
+ self.server.state = state
161
+ self.send_response(200)
162
+ self.send_header("Content-type", "text/plain; charset=utf-8")
163
+ self.end_headers()
164
+ self.wfile.write(b"Authorization successful. You can close this tab.")
165
+
166
+
167
+ def first_authorization(client_id: str, client_secret: str | None, *, scope: str, redirect_uri: str) -> None:
168
+ """Perform auth via browser and cache tokens.
169
+
170
+ Uses Authorization Code + PKCE (S256) as required by XF for public clients.
171
+ """
172
+ # Step 1: Generate PKCE + state, then build the authorize URL
173
+ state = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=")
174
+ code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=") # 43 chars base64url
175
+ code_challenge = (
176
+ base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
177
+ .decode("ascii")
178
+ .rstrip("=")
179
+ )
180
+
181
+ params = {
182
+ "response_type": "code",
183
+ "client_id": client_id,
184
+ "redirect_uri": redirect_uri,
185
+ "scope": scope or "",
186
+ "state": state,
187
+ "code_challenge": code_challenge,
188
+ "code_challenge_method": "S256",
189
+ }
190
+ auth_url = f"{AUTHORIZATION_ENDPOINT}?{urlencode(params)}"
191
+
192
+ # Step 2: Open the authorize URL in the user's browser
193
+ try:
194
+ success = webbrowser.open(auth_url)
195
+ if success:
196
+ print("Please login and authorize the app in your web browser.")
197
+ else:
198
+ raise RuntimeError("Browser could not be opened.")
199
+ except Exception:
200
+ print("Unable to open the web browser automatically.")
201
+ print("Please open the following URL manually in your browser to authorize the app:")
202
+ print(auth_url)
203
+
204
+ # Step 3: Wait for the authorization code via the loopback redirect
205
+ authorization_code = run_server(expected_state=state, redirect_uri=redirect_uri)
206
+
207
+ # Step 4: Exchange the authorization code for tokens
208
+ payload = {
209
+ "client_id": client_id,
210
+ "grant_type": "authorization_code",
211
+ "redirect_uri": redirect_uri,
212
+ "code": authorization_code,
213
+ "code_verifier": code_verifier,
214
+ }
215
+ if client_secret:
216
+ payload["client_secret"] = client_secret
217
+
218
+ headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
219
+ response = httpx.post(TOKEN_ENDPOINT, headers=headers, data=payload, timeout=10.0)
220
+ if not response.is_success:
221
+ details = response.text.strip()
222
+ if details:
223
+ raise RuntimeError(f"Failed to retrieve tokens.\n{details}")
224
+ raise RuntimeError("Failed to retrieve tokens.")
225
+
226
+ token_data = response.json()
227
+ if token_data.get("error"):
228
+ raise RuntimeError(
229
+ f"OAuth token error: {token_data.get('error')} {token_data.get('error_description', '')}".strip()
230
+ )
231
+
232
+ # Step 5: Cache tokens and basic user info
233
+ store_tokens_in_keyring(token_data)
234
+ print("Authorization successful and tokens cached.")
235
+
236
+ # Cache basic user info (best-effort; not required for API auth).
237
+ try:
238
+ _cache_user_info(token_data.get("access_token"))
239
+ except Exception:
240
+ pass
241
+
242
+
243
+ def _cache_user_info(access_token: str | None) -> None:
244
+ """Fetch /api/me and cache username/user_id (best-effort)."""
245
+ if not access_token:
246
+ return
247
+ headers = {"Authorization": f"Bearer {access_token}"}
248
+ resp = httpx.get(f"{BASE_URL}/api/me", headers=headers, timeout=10.0)
249
+ resp.raise_for_status()
250
+ data = resp.json()
251
+ me = data.get("me") or {}
252
+ if me.get("username"):
253
+ set_username(str(me["username"]))
254
+ if me.get("user_id"):
255
+ set_user_id(str(me["user_id"]))
256
+
257
+
258
+ def authorize():
259
+ # If using env var (mainly for CI), skip OAuth entirely
260
+ if os.environ.get('REDGUIDES_API_KEY'):
261
+ return
262
+
263
+ client_id = _get_setting("OAUTH_CLIENT_ID")
264
+ client_secret = _get_setting("OAUTH_CLIENT_SECRET", "") # optional (confidential clients only)
265
+ scope = _get_setting("OAUTH_SCOPE", "user:read resource:read resource:write attachment:write")
266
+ redirect_uri = _get_setting("OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI)
267
+
268
+ if not client_id:
269
+ raise RuntimeError("OAuth client is not configured.")
270
+
271
+ data = get_cached_tokens()
272
+
273
+ # Fast path: valid access token already cached
274
+ if data.get("access_token") and token_is_valid():
275
+ return
276
+
277
+ # Try refresh if we have a refresh token
278
+ if data.get("refresh_token"):
279
+ print("Attempting to refresh access token...")
280
+ if refresh_token(client_id, client_secret, redirect_uri=redirect_uri):
281
+ print("Token refreshed successfully.")
282
+ return
283
+
284
+ # Fall back to interactive authorization
285
+ print("Performing full authorization...")
286
+ first_authorization(client_id, client_secret, scope=scope, redirect_uri=redirect_uri)
287
+
288
+
289
+ def _port_from_redirect_uri(redirect_uri: str) -> int:
290
+ try:
291
+ parsed = urlparse(redirect_uri)
292
+ if parsed.port:
293
+ return int(parsed.port)
294
+ except Exception:
295
+ pass
296
+ return DEFAULT_LOOPBACK_PORT
297
+
298
+
299
+ def run_server(*, expected_state: str, redirect_uri: str, timeout_seconds: int = 300) -> str:
300
+ """Start a loopback HTTP server and wait for XF's OAuth redirect."""
301
+ port = _port_from_redirect_uri(redirect_uri)
302
+ server_address = ("127.0.0.1", port)
303
+ httpd = HTTPServer(server_address, OAuthCallbackHandler)
304
+ httpd.timeout = 5 # allow periodic timeout checks
305
+ httpd.code = None
306
+ httpd.state = None
307
+ httpd.error = None
308
+
309
+ start = time.time()
310
+ while True:
311
+ if httpd.error:
312
+ raise RuntimeError(f"OAuth authorization error: {httpd.error}")
313
+ if httpd.code:
314
+ break
315
+ if time.time() - start > timeout_seconds:
316
+ raise TimeoutError("Timed out waiting for OAuth authorization response.")
317
+ httpd.handle_request()
318
+
319
+ if httpd.state != expected_state:
320
+ raise RuntimeError("Received OAuth response with invalid state.")
321
+
322
+ return httpd.code
323
+
324
+
325
+ def store_tokens_in_keyring(data):
326
+ """Store OAuth tokens securely in keyring; store non-secrets in disk cache."""
327
+ keyring.set_password(KEYRING_SERVICE_NAME, "access_token", data["access_token"])
328
+ keyring.set_password(KEYRING_SERVICE_NAME, "refresh_token", data["refresh_token"])
329
+
330
+ expires_at = datetime.now().timestamp() + int(data.get("expires_in", 0) or 0)
331
+ set_token_expiry(str(expires_at))
332
+
333
+
334
+ def refresh_token(client_id: str, client_secret: str | None, *, redirect_uri: str) -> bool:
335
+ refresh_token_value = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
336
+ if not refresh_token_value:
337
+ return False
338
+
339
+ payload = {
340
+ "client_id": client_id,
341
+ "grant_type": "refresh_token",
342
+ "redirect_uri": redirect_uri,
343
+ "refresh_token": refresh_token_value,
344
+ # XF 2.3 requires code_verifier for public clients even on refresh. A static non-empty value works.
345
+ "code_verifier": _REFRESH_CODE_VERIFIER,
346
+ }
347
+ if client_secret:
348
+ payload["client_secret"] = client_secret
349
+
350
+ headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
351
+ response = httpx.post(TOKEN_ENDPOINT, headers=headers, data=payload, timeout=10.0)
352
+ if not response.is_success:
353
+ print("Failed to refresh access token.")
354
+ print(response.text)
355
+ return False
356
+
357
+ new_token_data = response.json()
358
+ if new_token_data.get("error"):
359
+ print(f"OAuth token error: {new_token_data.get('error')} {new_token_data.get('error_description', '')}".strip())
360
+ return False
361
+
362
+ store_tokens_in_keyring(new_token_data)
363
+ return True
364
+
365
+
366
+ def token_is_valid():
367
+ """Check if the access token is still valid."""
368
+ expires_at_str = get_token_expiry()
369
+ if expires_at_str:
370
+ expires_at = datetime.fromtimestamp(float(expires_at_str))
371
+ now = datetime.now()
372
+ is_valid = now < expires_at - timedelta(minutes=5) # Buffer of 5 minutes
373
+ return is_valid
374
+ else:
375
+ return False
376
+
377
+
378
+ def get_cached_tokens():
379
+ """Retrieve cached OAuth tokens from keyring and non-secrets from disk cache."""
380
+ data = {}
381
+ data["access_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
382
+ data["refresh_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
383
+ data["username"] = get_username_from_cache()
384
+ data["user_id"] = get_user_id()
385
+ return data
386
+
387
+
388
+ def logout():
389
+ """Clear stored credentials from keyring and all disk caches."""
390
+ from redfetch import meta
391
+
392
+ keyring_credentials = ["access_token", "refresh_token", "api_key"]
393
+ legacy_credentials = ["user_id", "username", "expires_at"]
394
+
395
+ credentials_deleted = False
396
+
397
+ for credential in keyring_credentials + legacy_credentials:
398
+ try:
399
+ keyring.delete_password(KEYRING_SERVICE_NAME, credential)
400
+ credentials_deleted = True
401
+ except keyring.errors.PasswordDeleteError:
402
+ pass
403
+
404
+ try:
405
+ clear_disk_cache()
406
+ meta.clear_pypi_cache()
407
+ net.clear_manifest_cache()
408
+ credentials_deleted = True
409
+ except Exception:
410
+ pass
411
+
412
+ if credentials_deleted:
413
+ print("You have been logged out successfully.")
414
+ else:
415
+ print("No active session found. You were not logged in.")
416
+
417
+
418
+ # ---------------------------------------------------------------------------
419
+ # API identity resolution
420
+ # ---------------------------------------------------------------------------
421
+
422
+ async def fetch_me(client: httpx.AsyncClient) -> Optional[dict]:
423
+ """Fetch current user info from /api/me."""
424
+ url = f'{BASE_URL}/api/me'
425
+ try:
426
+ data = await net.get_json(client, url)
427
+ return {
428
+ 'user_id': str(data['me']['user_id']),
429
+ 'username': data['me']['username']
430
+ }
431
+ except Exception as e:
432
+ print(f"Failed to retrieve user info: {e}")
433
+ return None
434
+
435
+
436
+ async def fetch_user_id_from_api(api_key):
437
+ """Fetch user_id using the API key; caches it."""
438
+ async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
439
+ me = await fetch_me(client)
440
+ if me:
441
+ set_user_id(me['user_id'])
442
+ return me['user_id']
443
+ return None
444
+
445
+
446
+ async def fetch_username(api_key, cache=True):
447
+ """Fetch username via API key; caches username and user_id."""
448
+ async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
449
+ me = await fetch_me(client)
450
+ if me:
451
+ if cache:
452
+ set_username(me['username'])
453
+ set_user_id(me['user_id'])
454
+ return me['username']
455
+ return "Unknown"
456
+
457
+
458
+ async def get_api_headers():
459
+ """Return auth headers for XenForo API requests.
460
+
461
+ Priority order:
462
+ 1) API key via env: `REDGUIDES_API_KEY`
463
+ 2) Native OAuth2: cached `access_token` from keyring
464
+ """
465
+ api_key = os.environ.get('REDGUIDES_API_KEY')
466
+ if api_key:
467
+ headers = {'XF-Api-Key': api_key}
468
+ user_id = os.environ.get('REDGUIDES_USER_ID')
469
+ if not user_id:
470
+ user_id = await fetch_user_id_from_api(api_key)
471
+ if not user_id:
472
+ raise RuntimeError("Unable to retrieve user ID using the provided API key.")
473
+ headers['XF-Api-User'] = str(user_id)
474
+ return headers
475
+
476
+ access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
477
+ refresh_tok = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
478
+
479
+ if access_token or refresh_tok:
480
+ if access_token and token_is_valid():
481
+ return {"Authorization": f"Bearer {access_token}"}
482
+
483
+ if refresh_tok:
484
+ client_id = _get_setting("OAUTH_CLIENT_ID")
485
+ client_secret = _get_setting("OAUTH_CLIENT_SECRET", "")
486
+ redirect_uri = _get_setting("OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI)
487
+
488
+ if not client_id:
489
+ raise RuntimeError("OAuth client is not configured.")
490
+
491
+ refreshed = await asyncio.to_thread(
492
+ refresh_token,
493
+ str(client_id),
494
+ str(client_secret or ""),
495
+ redirect_uri=str(redirect_uri),
496
+ )
497
+ if refreshed:
498
+ access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
499
+ if access_token:
500
+ return {"Authorization": f"Bearer {access_token}"}
501
+
502
+ raise RuntimeError("OAuth token refresh failed. Please run `redfetch logout` and authorize again.")
503
+
504
+ raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
505
+
506
+ raise RuntimeError(
507
+ "Not authenticated. Set REDGUIDES_API_KEY (and optionally REDGUIDES_USER_ID), "
508
+ "or authorize via OAuth."
509
+ )
510
+
511
+
512
+ async def get_username():
513
+ """Fetch the username from the environment variable, disk cache, or API."""
514
+ username = os.environ.get('REDFETCH_USERNAME')
515
+ if username:
516
+ return username
517
+
518
+ username = get_username_from_cache()
519
+ if username:
520
+ return username
521
+
522
+ api_key = os.environ.get('REDGUIDES_API_KEY')
523
+ if api_key:
524
+ username = await fetch_username(api_key)
525
+ if username != "Unknown":
526
+ return username
527
+ raise RuntimeError("Unable to retrieve username using the provided API key.")
528
+
529
+ access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
530
+ refresh_tok = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
531
+ if access_token or refresh_tok:
532
+ headers = await get_api_headers()
533
+ async with httpx.AsyncClient(headers=headers, http2=True) as client:
534
+ me = await fetch_me(client)
535
+ if me and me.get("username"):
536
+ set_username(me["username"])
537
+ set_user_id(me["user_id"])
538
+ return me["username"]
539
+ raise RuntimeError("Unable to retrieve username using the stored OAuth token.")
540
+
541
+ raise RuntimeError("Username not found. Set REDGUIDES_API_KEY or authorize via OAuth.")
542
+
543
+
544
+ def initialize_keyring():
545
+ # Skip keyring init if using env var (mainly for CI on Linux)
546
+ if os.environ.get('REDGUIDES_API_KEY'):
547
+ return
548
+
549
+ try:
550
+ # Attempt to use the keyring to trigger any potential errors
551
+ keyring.get_password('test_service', 'test_user')
552
+ except (NoKeyringError, ModuleNotFoundError):
553
+ raise RuntimeError(
554
+ "No suitable keyring backend found, probably because you're not on Windows.\n\n"
555
+ "Please install `keyrings.alt` by running:\n"
556
+ " pipx inject redfetch keyrings.alt\n\n"
557
+ "Then restart the application."
558
+ )
559
+ except Exception as e:
560
+ # Catch any other exceptions that may occur and handle them gracefully
561
+ raise RuntimeError(
562
+ f"An error occurred while initializing keyring: {e}\n\n"
563
+ "Please ensure that a suitable keyring backend is available."
564
+ ) from e
565
+
566
+
567
+ if __name__ == "__main__":
568
+ initialize_keyring()
569
+ if not os.environ.get("REDGUIDES_API_KEY"):
570
+ # Initialize config lazily if invoked directly, so this can be used as a standalone script.
571
+ if config.settings is None:
572
+ config.initialize_config()
573
+ authorize()