hpde-analytics-cli 1.0.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.
@@ -0,0 +1,529 @@
1
+ """
2
+ OAuth 1.0a authentication module for MotorsportsReg API.
3
+
4
+ Implements the three-legged OAuth flow:
5
+ 1. Request token retrieval
6
+ 2. User authorization
7
+ 3. Access token exchange
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import socket
13
+ import threading
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Tuple
18
+ from urllib.parse import parse_qs, urlparse
19
+
20
+ from requests_oauthlib import OAuth1Session
21
+
22
+ # Default callback port for local OAuth flow
23
+ DEFAULT_CALLBACK_PORT = 8089
24
+
25
+
26
+ class OAuthCallbackHandler(BaseHTTPRequestHandler):
27
+ """HTTP request handler that captures OAuth callback parameters."""
28
+
29
+ oauth_verifier = None
30
+ oauth_token = None
31
+
32
+ def do_GET(self):
33
+ """Handle GET request from OAuth callback."""
34
+ # Parse the query string
35
+ parsed = urlparse(self.path)
36
+ params = parse_qs(parsed.query)
37
+
38
+ # Log what we received for debugging
39
+ print(f"\n [Callback] Received request: {self.path}")
40
+
41
+ # Ignore non-callback requests (favicon, root path, etc.)
42
+ if not parsed.path.startswith("/callback"):
43
+ self.send_response(200)
44
+ self.send_header("Content-type", "text/html")
45
+ self.end_headers()
46
+ response = """
47
+ <html>
48
+ <head><title>Waiting...</title></head>
49
+ <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
50
+ <p>Waiting for MotorsportsReg authorization callback...</p>
51
+ <p>Please complete the authorization on motorsportreg.com</p>
52
+ </body>
53
+ </html>
54
+ """
55
+ self.wfile.write(response.encode())
56
+ return
57
+
58
+ print(f" [Callback] OAuth parameters: {params}")
59
+
60
+ # Extract OAuth parameters
61
+ OAuthCallbackHandler.oauth_verifier = params.get("oauth_verifier", [None])[0]
62
+ OAuthCallbackHandler.oauth_token = params.get("oauth_token", [None])[0]
63
+
64
+ # Check for OAuth error response
65
+ oauth_error = params.get("error", [None])[0]
66
+ error_description = params.get("error_description", ["Unknown error"])[0]
67
+
68
+ # Send response to browser
69
+ self.send_response(200)
70
+ self.send_header("Content-type", "text/html")
71
+ self.end_headers()
72
+
73
+ if OAuthCallbackHandler.oauth_verifier:
74
+ response = """
75
+ <html>
76
+ <head><title>Authorization Successful</title></head>
77
+ <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
78
+ <h1 style="color: green;">Authorization Successful!</h1>
79
+ <p>You can close this window and return to the terminal.</p>
80
+ <p>Verification code received. The script will continue automatically.</p>
81
+ </body>
82
+ </html>
83
+ """
84
+ elif oauth_error:
85
+ response = f"""
86
+ <html>
87
+ <head><title>Authorization Denied</title></head>
88
+ <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
89
+ <h1 style="color: red;">Authorization Denied</h1>
90
+ <p>Error: {oauth_error}</p>
91
+ <p>{error_description}</p>
92
+ </body>
93
+ </html>
94
+ """
95
+ else:
96
+ response = f"""
97
+ <html>
98
+ <head><title>Authorization Issue</title></head>
99
+ <body style="font-family: Arial, sans-serif; text-align: center; padding: 50px;">
100
+ <h1 style="color: orange;">Missing Verification Code</h1>
101
+ <p>Received callback but no oauth_verifier parameter.</p>
102
+ <p>Parameters received: {params}</p>
103
+ </body>
104
+ </html>
105
+ """
106
+
107
+ self.wfile.write(response.encode())
108
+
109
+ def log_message(self, format, *args):
110
+ """Suppress HTTP server logging."""
111
+ pass
112
+
113
+
114
+ class MSROAuth:
115
+ """Handles OAuth 1.0a authentication with MotorsportsReg API."""
116
+
117
+ REQUEST_TOKEN_URL = "/rest/tokens/request"
118
+ AUTHORIZE_URL = "/index.cfm/event/oauth" # MSR-specific OAuth authorization page
119
+ ACCESS_TOKEN_URL = "/rest/tokens/access"
120
+ ME_URL = "/rest/me.json" # Use .json suffix for JSON response
121
+
122
+ def __init__(
123
+ self,
124
+ consumer_key: str,
125
+ consumer_secret: str,
126
+ base_url: str = "https://api.motorsportreg.com",
127
+ callback_url: Optional[str] = None,
128
+ callback_port: int = DEFAULT_CALLBACK_PORT,
129
+ token_file: Optional[str] = None,
130
+ ):
131
+ """
132
+ Initialize the OAuth handler.
133
+
134
+ Args:
135
+ consumer_key: OAuth consumer key from MotorsportsReg
136
+ consumer_secret: OAuth consumer secret from MotorsportsReg
137
+ base_url: API base URL
138
+ callback_url: OAuth callback URL (defaults to localhost)
139
+ callback_port: Port for local callback server
140
+ token_file: Path to store/load access tokens
141
+ """
142
+ self.consumer_key = consumer_key
143
+ self.consumer_secret = consumer_secret
144
+ self.base_url = base_url.rstrip("/")
145
+ self.callback_port = callback_port
146
+ self.callback_url = callback_url or f"http://localhost:{callback_port}/callback"
147
+
148
+ # Token storage
149
+ if token_file is None:
150
+ project_root = Path(__file__).parent.parent.parent
151
+ self.token_file = project_root / "tokens" / "access_token.json"
152
+ else:
153
+ self.token_file = Path(token_file)
154
+
155
+ # OAuth tokens (populated during auth flow)
156
+ self.request_token = None
157
+ self.request_token_secret = None
158
+ self.access_token = None
159
+ self.access_token_secret = None
160
+ self.profile_id: Optional[str] = None
161
+ self.organizations: List[Dict[str, Any]] = []
162
+
163
+ # Try to load existing tokens
164
+ self._load_tokens()
165
+
166
+ def _load_tokens(self) -> bool:
167
+ """
168
+ Load access tokens from file if they exist.
169
+
170
+ Returns:
171
+ True if tokens were loaded successfully, False otherwise
172
+ """
173
+ if not self.token_file.exists():
174
+ return False
175
+
176
+ try:
177
+ with open(self.token_file, "r") as f:
178
+ data = json.load(f)
179
+ self.access_token = data.get("access_token")
180
+ self.access_token_secret = data.get("access_token_secret")
181
+ self.profile_id = data.get("profile_id")
182
+ self.organizations = data.get("organizations", [])
183
+
184
+ if self.access_token and self.access_token_secret:
185
+ print(f"Loaded existing access tokens from {self.token_file}")
186
+ return True
187
+ except (json.JSONDecodeError, IOError) as e:
188
+ print(f"Warning: Could not load tokens: {e}")
189
+
190
+ return False
191
+
192
+ def _save_tokens(self) -> None:
193
+ """Save access tokens to file."""
194
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
195
+
196
+ data = {
197
+ "access_token": self.access_token,
198
+ "access_token_secret": self.access_token_secret,
199
+ "profile_id": self.profile_id,
200
+ "organizations": self.organizations,
201
+ }
202
+
203
+ with open(self.token_file, "w") as f:
204
+ json.dump(data, f, indent=2)
205
+
206
+ print(f"Access tokens saved to {self.token_file}")
207
+
208
+ def has_valid_tokens(self) -> bool:
209
+ """Check if we have access tokens that might be valid."""
210
+ return bool(self.access_token and self.access_token_secret)
211
+
212
+ def get_request_token(self) -> Tuple[str, str]:
213
+ """
214
+ Step 1: Obtain a request token from MotorsportsReg.
215
+
216
+ Returns:
217
+ Tuple of (request_token, request_token_secret)
218
+
219
+ Raises:
220
+ Exception: If request token retrieval fails
221
+ """
222
+ print("\n[Step 1/3] Requesting temporary token...")
223
+
224
+ oauth = OAuth1Session(
225
+ self.consumer_key,
226
+ client_secret=self.consumer_secret,
227
+ callback_uri=self.callback_url,
228
+ )
229
+
230
+ url = f"{self.base_url}{self.REQUEST_TOKEN_URL}"
231
+ response = oauth.fetch_request_token(url)
232
+
233
+ self.request_token = response.get("oauth_token")
234
+ self.request_token_secret = response.get("oauth_token_secret")
235
+
236
+ if not self.request_token:
237
+ raise Exception("Failed to obtain request token")
238
+
239
+ print(f" Request token obtained: {self.request_token[:20]}...")
240
+ return self.request_token, self.request_token_secret
241
+
242
+ def get_authorization_url(self) -> str:
243
+ """
244
+ Step 2: Generate the authorization URL for user to visit.
245
+
246
+ Returns:
247
+ URL string for user to authorize the application
248
+ """
249
+ if not self.request_token:
250
+ raise Exception("Must obtain request token first")
251
+
252
+ # MotorsportsReg uses the main site for authorization
253
+ auth_base = "https://www.motorsportreg.com"
254
+ auth_url = f"{auth_base}{self.AUTHORIZE_URL}?oauth_token={self.request_token}"
255
+
256
+ print("\n[Step 2/3] User authorization required")
257
+ print("-" * 60)
258
+ print("Please visit this URL to authorize the application:")
259
+ print(f"\n {auth_url}\n")
260
+ print("-" * 60)
261
+
262
+ return auth_url
263
+
264
+ def get_access_token(self, verifier: str) -> dict:
265
+ """
266
+ Step 3: Exchange request token for access token.
267
+
268
+ Args:
269
+ verifier: The oauth_verifier code from authorization
270
+
271
+ Returns:
272
+ Dict containing access_token, access_token_secret, profile_id
273
+
274
+ Raises:
275
+ Exception: If access token exchange fails
276
+ """
277
+ print("\n[Step 3/3] Exchanging for access token...")
278
+
279
+ oauth = OAuth1Session(
280
+ self.consumer_key,
281
+ client_secret=self.consumer_secret,
282
+ resource_owner_key=self.request_token,
283
+ resource_owner_secret=self.request_token_secret,
284
+ verifier=verifier,
285
+ )
286
+
287
+ url = f"{self.base_url}{self.ACCESS_TOKEN_URL}"
288
+ response = oauth.fetch_access_token(url)
289
+
290
+ self.access_token = response.get("oauth_token")
291
+ self.access_token_secret = response.get("oauth_token_secret")
292
+ self.profile_id = response.get("userid") or response.get("profile_id")
293
+
294
+ if not self.access_token:
295
+ raise Exception("Failed to obtain access token")
296
+
297
+ print(f" Access token obtained: {self.access_token[:20]}...")
298
+ if self.profile_id:
299
+ print(f" Profile ID: {self.profile_id}")
300
+
301
+ # Save tokens for future use
302
+ self._save_tokens()
303
+
304
+ return {
305
+ "access_token": self.access_token,
306
+ "access_token_secret": self.access_token_secret,
307
+ "profile_id": self.profile_id,
308
+ }
309
+
310
+ def get_oauth_session(self) -> OAuth1Session:
311
+ """
312
+ Get an authenticated OAuth1Session for making API requests.
313
+
314
+ Returns:
315
+ Configured OAuth1Session instance
316
+
317
+ Raises:
318
+ Exception: If no valid access tokens available
319
+ """
320
+ if not self.has_valid_tokens():
321
+ raise Exception("No valid access tokens. Please authenticate first.")
322
+
323
+ session = OAuth1Session(
324
+ self.consumer_key,
325
+ client_secret=self.consumer_secret,
326
+ resource_owner_key=self.access_token,
327
+ resource_owner_secret=self.access_token_secret,
328
+ )
329
+
330
+ # Set default headers to request JSON responses
331
+ session.headers.update(
332
+ {
333
+ "Accept": "application/json",
334
+ "Content-Type": "application/json",
335
+ }
336
+ )
337
+
338
+ return session
339
+
340
+ def validate_connection(self) -> Dict[str, Any]:
341
+ """
342
+ Validate the OAuth connection by calling /rest/me endpoint.
343
+
344
+ Returns:
345
+ User profile data including organizations
346
+
347
+ Raises:
348
+ Exception: If validation fails
349
+ """
350
+ print("\nValidating connection...")
351
+
352
+ session = self.get_oauth_session()
353
+ url = f"{self.base_url}{self.ME_URL}"
354
+
355
+ response = session.get(url)
356
+
357
+ if response.status_code == 401:
358
+ # Tokens are invalid, clear them
359
+ self.access_token = None
360
+ self.access_token_secret = None
361
+ if self.token_file.exists():
362
+ self.token_file.unlink()
363
+ raise Exception("Authentication failed - tokens are invalid or expired")
364
+
365
+ response.raise_for_status()
366
+
367
+ data = response.json()
368
+
369
+ # MSR wraps responses in {"response": {"profile": {...}}}
370
+ if "response" in data:
371
+ data = data["response"]
372
+ if "profile" in data:
373
+ profile_data = data["profile"]
374
+ else:
375
+ profile_data = data
376
+
377
+ # Extract organization info
378
+ self.organizations = profile_data.get("organizations", [])
379
+
380
+ # Update saved tokens with organization info
381
+ self._save_tokens()
382
+
383
+ return profile_data
384
+
385
+ def _start_callback_server(self) -> HTTPServer:
386
+ """Start a local HTTP server to receive the OAuth callback."""
387
+ # Reset handler state
388
+ OAuthCallbackHandler.oauth_verifier = None
389
+ OAuthCallbackHandler.oauth_token = None
390
+
391
+ server = HTTPServer(("localhost", self.callback_port), OAuthCallbackHandler)
392
+ server.timeout = 300 # 5 minute timeout
393
+
394
+ return server
395
+
396
+ def _wait_for_callback(self, server: HTTPServer, max_requests: int = 10) -> str:
397
+ """Wait for the OAuth callback and return the verifier."""
398
+ print(f"\nWaiting for authorization callback on port {self.callback_port}...")
399
+ print("(Press Ctrl+C to cancel)\n")
400
+
401
+ # Handle multiple requests (browser may make favicon/other requests)
402
+ for _ in range(max_requests):
403
+ server.handle_request()
404
+
405
+ verifier = OAuthCallbackHandler.oauth_verifier
406
+ if verifier:
407
+ return verifier
408
+
409
+ raise Exception(
410
+ "No verification code received from callback. "
411
+ "Please ensure you complete the authorization on motorsportreg.com"
412
+ )
413
+
414
+ def run_auth_flow(self, auto_open_browser: bool = True) -> dict:
415
+ """
416
+ Run the complete OAuth authentication flow interactively.
417
+
418
+ Args:
419
+ auto_open_browser: Automatically open authorization URL in browser
420
+
421
+ Returns:
422
+ User profile data from /rest/me
423
+
424
+ Raises:
425
+ Exception: If any step of the auth flow fails
426
+ """
427
+ print("=" * 60)
428
+ print("MotorsportsReg OAuth 1.0a Authentication")
429
+ print("=" * 60)
430
+
431
+ # Check for existing valid tokens
432
+ if self.has_valid_tokens():
433
+ print("\nFound existing access tokens. Validating...")
434
+ try:
435
+ profile = self.validate_connection()
436
+ print("\n[OK] Connection validated successfully!")
437
+ return profile
438
+ except Exception as e:
439
+ print(f"\nExisting tokens invalid: {e}")
440
+ print("Starting fresh authentication flow...")
441
+
442
+ # Start callback server before requesting token
443
+ server = self._start_callback_server()
444
+
445
+ try:
446
+ # Step 1: Get request token
447
+ self.get_request_token()
448
+
449
+ # Step 2: Get authorization URL
450
+ auth_url = self.get_authorization_url()
451
+
452
+ # Open browser automatically if requested
453
+ if auto_open_browser:
454
+ print("\n" + "!" * 60)
455
+ print("IMPORTANT: Copy and paste this URL into your browser:")
456
+ print(f"\n {auth_url}\n")
457
+ print("!" * 60)
458
+ print("\nAttempting to open browser automatically...")
459
+ try:
460
+ webbrowser.open(auth_url)
461
+ except Exception as e:
462
+ print(f" Could not open browser automatically: {e}")
463
+ print("\nIf the wrong page opens, manually navigate to the URL above.")
464
+ else:
465
+ print("\nPlease open the URL above in your browser.")
466
+
467
+ # Step 3: Wait for callback with verifier
468
+ verifier = self._wait_for_callback(server)
469
+ print(f"\n Received verifier: {verifier[:20]}...")
470
+
471
+ # Step 4: Exchange for access token
472
+ self.get_access_token(verifier)
473
+
474
+ # Validate the new tokens
475
+ profile = self.validate_connection()
476
+
477
+ print("\n" + "=" * 60)
478
+ print("[OK] Authentication successful!")
479
+ print("=" * 60)
480
+
481
+ return profile
482
+
483
+ finally:
484
+ server.server_close()
485
+
486
+
487
+ def create_oauth_from_env() -> MSROAuth:
488
+ """
489
+ Create an MSROAuth instance using credentials from keyring or environment.
490
+
491
+ Credential sources (in priority order):
492
+ 1. System keyring (if available and configured)
493
+ 2. Environment variables (MSR_CONSUMER_KEY, MSR_CONSUMER_SECRET)
494
+
495
+ Other environment variables:
496
+ MSR_BASE_URL: API base URL (optional)
497
+ MSR_CALLBACK_URL: OAuth callback URL (optional, defaults to localhost)
498
+ MSR_CALLBACK_PORT: Port for callback server (optional, default 8089)
499
+
500
+ Returns:
501
+ Configured MSROAuth instance
502
+
503
+ Raises:
504
+ ValueError: If credentials cannot be found from any source
505
+ """
506
+ # Import credential manager to get credentials from keyring or env
507
+ from hpde_analytics_cli.auth.credentials import CredentialManager
508
+
509
+ credential_manager = CredentialManager()
510
+
511
+ try:
512
+ consumer_key, consumer_secret = credential_manager.get_credentials()
513
+ except ValueError:
514
+ raise ValueError(
515
+ "No credentials found. Run with --configure to set up credentials, "
516
+ "or set MSR_CONSUMER_KEY and MSR_CONSUMER_SECRET environment variables."
517
+ )
518
+
519
+ base_url = os.getenv("MSR_BASE_URL", "https://api.motorsportreg.com")
520
+ callback_url = os.getenv("MSR_CALLBACK_URL") # None = use localhost default
521
+ callback_port = int(os.getenv("MSR_CALLBACK_PORT", str(DEFAULT_CALLBACK_PORT)))
522
+
523
+ return MSROAuth(
524
+ consumer_key=consumer_key,
525
+ consumer_secret=consumer_secret,
526
+ base_url=base_url,
527
+ callback_url=callback_url,
528
+ callback_port=callback_port,
529
+ )