"""Swiftly bearer-JWT auto-mint via Firebase REST. Replaces the static ``SWIFTLY_BEARER_TOKEN`` env var. The Lucky California storefront is a Firebase Web SDK client of the ``swiftly-lu-prod`` project; its public ``config.json`` exposes the ``firebaseApiKey``, and Firebase Identity Toolkit's anonymous-signup endpoint mints a fresh ID token on demand. The token is identical in shape to the one a real browser session produces, and the Swiftly product API accepts it. Process-local cache only — a backend restart re-mints. Mint cost is ~500ms; a 1h-TTL token is reused for ~55min of that hour. Spec: ``docs/specs/2026-05-06-swiftly-token-auto-mint.md``. """ from __future__ import annotations import base64 import json import logging import threading import time from typing import Any, Dict, Tuple import requests logger = logging.getLogger(__name__) class SwiftlyAuthMintError(Exception): """Raised when minting a fresh Swiftly JWT fails. Surface the message verbatim to ``ScrapeLog.error_message`` so the operator sees which Firebase-or-Lucky path broke. """ _CONFIG_URL = "https://luckysupermarkets.com/config.json" _FIREBASE_SIGNUP_URL = "https://identitytoolkit.googleapis.com/v1/accounts:signUp" _EXPECTED_ISS = "https://securetoken.google.com/swiftly-lu-prod" _USER_AGENT = ( "MealPlannerBot/1.0 (+https://mealplanner.local; contact peter@research.bike)" ) # Refresh when the cached token has < this many seconds of life left. _REFRESH_MARGIN_SECONDS = 300 # Reject minted tokens whose exp is suspiciously close to now. _MIN_FRESH_TTL_SECONDS = 60 _lock = threading.Lock() _state: Dict[str, Any] = {"token": None, "exp": 0.0, "api_key": None} def _reset_cache_for_tests() -> None: """Clear the process-local cache. Test-only helper.""" with _lock: _state["token"] = None _state["exp"] = 0.0 _state["api_key"] = None def _decode_jwt_payload(token: str) -> Dict[str, Any]: parts = token.split(".") if len(parts) != 3: raise SwiftlyAuthMintError("malformed JWT (expected 3 segments)") payload_b64 = parts[1] payload_b64 += "=" * (-len(payload_b64) % 4) try: decoded = base64.urlsafe_b64decode(payload_b64.encode("ascii")) return json.loads(decoded) except (ValueError, json.JSONDecodeError) as exc: raise SwiftlyAuthMintError(f"could not decode JWT payload: {exc}") from exc def _fetch_firebase_api_key(timeout: int) -> str: cached = _state.get("api_key") if cached: return cached try: resp = requests.get( _CONFIG_URL, timeout=timeout, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"}, ) except requests.RequestException as exc: raise SwiftlyAuthMintError( f"could not fetch {_CONFIG_URL}: {exc}" ) from exc if resp.status_code != 200: raise SwiftlyAuthMintError( f"{_CONFIG_URL} returned {resp.status_code}: {resp.text[:200]}" ) try: data = resp.json() except ValueError as exc: raise SwiftlyAuthMintError( f"{_CONFIG_URL} returned non-JSON: {exc}" ) from exc api_key = data.get("firebaseApiKey") if not api_key: raise SwiftlyAuthMintError( "config.json missing firebaseApiKey — Lucky may have changed config shape" ) _state["api_key"] = api_key return api_key def mint_anonymous_token(timeout: int = 10) -> Tuple[str, float]: """Mint a fresh anonymous Firebase ID token. Returns ``(token, exp_epoch_seconds)``. Raises ``SwiftlyAuthMintError`` on any failure. Does not touch the cache — callers handle caching. """ api_key = _fetch_firebase_api_key(timeout=timeout) headers = { "Content-Type": "application/json", "Origin": "https://luckysupermarkets.com", "Referer": "https://luckysupermarkets.com/", "User-Agent": _USER_AGENT, } try: resp = requests.post( _FIREBASE_SIGNUP_URL, params={"key": api_key}, json={"returnSecureToken": True}, headers=headers, timeout=timeout, ) except requests.RequestException as exc: raise SwiftlyAuthMintError( f"Firebase signUp request failed: {exc}" ) from exc if resp.status_code != 200: raise SwiftlyAuthMintError( f"Firebase signUp returned {resp.status_code}: {resp.text[:200]}" ) try: body = resp.json() except ValueError as exc: raise SwiftlyAuthMintError( f"Firebase signUp returned non-JSON: {exc}" ) from exc id_token = body.get("idToken") if not id_token: raise SwiftlyAuthMintError("Firebase signUp response missing idToken") payload = _decode_jwt_payload(id_token) iss = payload.get("iss") if iss != _EXPECTED_ISS: raise SwiftlyAuthMintError( f"unexpected JWT iss: {iss!r} (expected {_EXPECTED_ISS!r})" ) exp = payload.get("exp") if not isinstance(exp, (int, float)): raise SwiftlyAuthMintError("JWT payload missing numeric exp claim") if float(exp) <= time.time() + _MIN_FRESH_TTL_SECONDS: raise SwiftlyAuthMintError( f"minted JWT already near expiry (exp={exp}, now={time.time():.0f})" ) return id_token, float(exp) def get_token() -> str: """Return a valid Swiftly bearer JWT, minting one if needed. A cached token is reused when it has more than ``_REFRESH_MARGIN_SECONDS`` of life remaining; otherwise a fresh token is minted via Firebase REST. """ with _lock: now = time.time() cached = _state.get("token") if cached and _state.get("exp", 0.0) > now + _REFRESH_MARGIN_SECONDS: return cached token, exp = mint_anonymous_token() _state["token"] = token _state["exp"] = exp logger.info( "swiftly_auth: minted fresh JWT (exp in %.0fs)", exp - time.time() ) return token