From 5e0a49e4ae4f2650623610053087c22483386826 Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Wed, 6 May 2026 15:19:30 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20AM-1=20swiftly=5Fauth=20module=20?= =?UTF-8?q?=E2=80=94=20Firebase=20REST=20anon-signUp=20+=20process=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the static SWIFTLY_BEARER_TOKEN env-var lookup with a JIT mint via the Firebase Identity Toolkit signUp endpoint, gated by the firebaseApiKey published in luckysupermarkets.com/config.json. - get_token(): returns cached JWT if exp > now+300s, else mints - mint_anonymous_token(): fetches API key, posts signUp with Origin/Referer headers, validates iss + exp on the returned JWT - SwiftlyAuthMintError surfaces verbatim to ScrapeLog.error_message - Process-local cache only; threading.Lock around mutate Tests: 4 unit tests covering fresh mint, cache hit, near-expiry re-mint, and Firebase non-200. Full suite: 92/92 green. Spec: docs/specs/2026-05-06-swiftly-token-auto-mint.md Wiring into lucky_ca_scraper deferred to AM-2. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/services/swiftly_auth.py | 177 +++++++++++++++++++++++++++ backend/tests/test_swiftly_auth.py | 152 +++++++++++++++++++++++ 2 files changed, 329 insertions(+) create mode 100644 backend/app/services/swiftly_auth.py create mode 100644 backend/tests/test_swiftly_auth.py diff --git a/backend/app/services/swiftly_auth.py b/backend/app/services/swiftly_auth.py new file mode 100644 index 0000000..bcec66e --- /dev/null +++ b/backend/app/services/swiftly_auth.py @@ -0,0 +1,177 @@ +"""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 diff --git a/backend/tests/test_swiftly_auth.py b/backend/tests/test_swiftly_auth.py new file mode 100644 index 0000000..c30d751 --- /dev/null +++ b/backend/tests/test_swiftly_auth.py @@ -0,0 +1,152 @@ +"""Offline tests for swiftly_auth — Firebase REST anon-signUp + cache. + +All tests run with ``requests.get``/``requests.post`` patched. No live +network. The live integration check belongs in +``scripts/spike_swiftly_ingest.py --confirm-live``. +""" +from __future__ import annotations + +import base64 +import json +import sys +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import requests + +BACKEND_DIR = Path(__file__).resolve().parent.parent +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from app.services import swiftly_auth # noqa: E402 +from app.services.swiftly_auth import ( # noqa: E402 + SwiftlyAuthMintError, + get_token, +) + +API_KEY = "AIzaSy-test-key" +EXPECTED_ISS = "https://securetoken.google.com/swiftly-lu-prod" + + +@pytest.fixture(autouse=True) +def _clear_cache(): + swiftly_auth._reset_cache_for_tests() + yield + swiftly_auth._reset_cache_for_tests() + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _make_jwt(*, exp: float, iss: str = EXPECTED_ISS) -> str: + header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode()) + payload = _b64url( + json.dumps({"iss": iss, "aud": "swiftly-lu-prod", "exp": int(exp)}).encode() + ) + sig = _b64url(b"signature-bytes") + return f"{header}.{payload}.{sig}" + + +def _config_response() -> MagicMock: + resp = MagicMock(spec=requests.Response) + resp.status_code = 200 + resp.json.return_value = {"firebaseApiKey": API_KEY} + return resp + + +def _signup_response(*, id_token: str, status_code: int = 200) -> MagicMock: + resp = MagicMock(spec=requests.Response) + resp.status_code = status_code + resp.text = "ok" if status_code == 200 else "boom" + if status_code == 200: + resp.json.return_value = {"idToken": id_token} + else: + resp.json.side_effect = ValueError("not json") + return resp + + +def test_get_token_mints_fresh_when_cache_empty(): + """First call fetches config.json, posts to Firebase, returns idToken.""" + fresh_exp = time.time() + 3600 + token = _make_jwt(exp=fresh_exp) + + with patch.object( + swiftly_auth.requests, "get", return_value=_config_response() + ) as mget, patch.object( + swiftly_auth.requests, + "post", + return_value=_signup_response(id_token=token), + ) as mpost: + result = get_token() + + assert result == token + mget.assert_called_once() + config_url = mget.call_args.args[0] + assert config_url == "https://luckysupermarkets.com/config.json" + + mpost.assert_called_once() + post_url = mpost.call_args.args[0] + assert post_url == "https://identitytoolkit.googleapis.com/v1/accounts:signUp" + assert mpost.call_args.kwargs["params"] == {"key": API_KEY} + headers = mpost.call_args.kwargs["headers"] + assert headers["Origin"] == "https://luckysupermarkets.com" + assert headers["Referer"] == "https://luckysupermarkets.com/" + + +def test_get_token_returns_cached_without_reminting(): + """Second call within the refresh window does NOT re-hit Firebase.""" + fresh_exp = time.time() + 3600 # well above the 300s refresh margin + token = _make_jwt(exp=fresh_exp) + + with patch.object( + swiftly_auth.requests, "get", return_value=_config_response() + ), patch.object( + swiftly_auth.requests, + "post", + return_value=_signup_response(id_token=token), + ) as mpost: + first = get_token() + second = get_token() + + assert first == second == token + assert mpost.call_count == 1, "expected exactly one signUp call across two get_token() calls" + + +def test_get_token_remints_when_cached_token_near_expiry(): + """A cached token within the refresh-margin window triggers a re-mint.""" + near_expiry = time.time() + 120 # < 300s margin → must refresh + fresh_exp = time.time() + 3600 + stale_token = _make_jwt(exp=near_expiry) + fresh_token = _make_jwt(exp=fresh_exp) + + swiftly_auth._state["api_key"] = API_KEY # skip the config fetch + swiftly_auth._state["token"] = stale_token + swiftly_auth._state["exp"] = near_expiry + + with patch.object( + swiftly_auth.requests, + "post", + return_value=_signup_response(id_token=fresh_token), + ) as mpost: + result = get_token() + + assert result == fresh_token + mpost.assert_called_once() + + +def test_mint_raises_on_firebase_non_200(): + """Firebase 403/400/etc. surfaces as SwiftlyAuthMintError with status detail.""" + with patch.object( + swiftly_auth.requests, "get", return_value=_config_response() + ), patch.object( + swiftly_auth.requests, + "post", + return_value=_signup_response(id_token="ignored", status_code=403), + ): + with pytest.raises(SwiftlyAuthMintError) as excinfo: + get_token() + + assert "403" in str(excinfo.value)