Public Access
feat: AM-1 swiftly_auth module — Firebase REST anon-signUp + process cache
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user