"""OAuth device flow authentication for the market scraper. Same flow as the artdag CLI client: 1. Request device code from account server 2. User approves in browser 3. Poll for access token 4. Save token to ~/.artdag/token.json (shared with artdag CLI) """ from __future__ import annotations import json import os import sys import time from pathlib import Path import httpx TOKEN_DIR = Path.home() / ".artdag" TOKEN_FILE = TOKEN_DIR / "token.json" _DEFAULT_ACCOUNT_SERVER = "https://account.rose-ash.com" def _get_account_server() -> str: return os.getenv("ARTDAG_ACCOUNT", _DEFAULT_ACCOUNT_SERVER) def load_token() -> dict: """Load saved token from ~/.artdag/token.json.""" if TOKEN_FILE.exists(): try: with open(TOKEN_FILE) as f: return json.load(f) except (json.JSONDecodeError, IOError): return {} return {} def save_token(token_data: dict): """Save token to ~/.artdag/token.json (mode 0600).""" TOKEN_DIR.mkdir(parents=True, exist_ok=True) with open(TOKEN_FILE, "w") as f: json.dump(token_data, f, indent=2) TOKEN_FILE.chmod(0o600) def get_access_token(require: bool = True) -> str | None: """Return the saved access token, or None. When *require* is True and no token is found, triggers interactive device-flow login (same as ``artdag login``). """ token = load_token().get("access_token") if not token and require: print("No saved token found — starting device-flow login...") token_data = login() token = token_data.get("access_token") return token def auth_headers() -> dict[str, str]: """Return Authorization header dict for HTTP requests.""" token = get_access_token(require=True) return {"Authorization": f"Bearer {token}"} def login() -> dict: """Interactive device-flow login (blocking, prints to stdout). Returns the token data dict on success. Exits on failure. """ account = _get_account_server() # 1. Request device code try: with httpx.Client(timeout=10) as client: resp = client.post( f"{account}/auth/device/authorize", json={"client_id": "artdag"}, ) resp.raise_for_status() data = resp.json() except httpx.HTTPError as e: print(f"Login failed: {e}", file=sys.stderr) sys.exit(1) device_code = data["device_code"] user_code = data["user_code"] verification_uri = data["verification_uri"] expires_in = data.get("expires_in", 900) interval = data.get("interval", 5) print("To sign in, open this URL in your browser:") print(f" {verification_uri}") print(f" and enter code: {user_code}") print() # Try to open browser try: import webbrowser webbrowser.open(verification_uri) except Exception: pass # 2. Poll for approval print("Waiting for authorization", end="", flush=True) deadline = time.time() + expires_in with httpx.Client(timeout=10) as client: while time.time() < deadline: time.sleep(interval) print(".", end="", flush=True) try: resp = client.post( f"{account}/auth/device/token", json={"device_code": device_code, "client_id": "artdag"}, ) data = resp.json() except httpx.HTTPError: continue error = data.get("error") if error == "authorization_pending": continue elif error == "expired_token": print("\nCode expired. Please try again.", file=sys.stderr) sys.exit(1) elif error == "access_denied": print("\nAuthorization denied.", file=sys.stderr) sys.exit(1) elif error: print(f"\nLogin failed: {error}", file=sys.stderr) sys.exit(1) # Success token_data = { "access_token": data["access_token"], "username": data.get("username", ""), "display_name": data.get("display_name", ""), } save_token(token_data) print(f"\nLogged in as {token_data['username'] or token_data['display_name']}") return token_data print("\nTimed out waiting for authorization.", file=sys.stderr) sys.exit(1)