Remove cross-DB relationships (CartItem.product, CartItem.market_place, OrderItem.product) that break with per-service databases. Denormalize product and marketplace fields onto cart_items/order_items at write time. - Add AP internal inbox infrastructure (shared/infrastructure/internal_inbox*) for synchronous inter-service writes via HMAC-authenticated POST - Cart inbox blueprint handles Add/Remove/Update rose:CartItem activities - Market app sends AP activities to cart inbox instead of writing CartItem directly - Cart services use denormalized columns instead of cross-DB hydration/joins - Add marketplaces-by-ids data endpoint to market service - Alembic migration adds denormalized columns to cart_items and order_items - Add OAuth device flow auth to market scraper persist_api (artdag client pattern) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
149 lines
4.4 KiB
Python
149 lines
4.4 KiB
Python
"""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)
|