fix: authenticate eBay public key fetch + add webhook health endpoint
Fixes recurring `400 Missing access token` errors in Snipe logs.
`_fetch_public_key()` was making unauthenticated GET requests to
eBay's Notification API (`/commerce/notification/v1/public_key/{kid}`),
which requires an app-level Bearer token (client_credentials grant).
Wires in the existing `EbayTokenManager` as a lazy module-level
singleton so every public key fetch carries a valid OAuth token.
Also adds `GET /api/ebay/webhook-health` for Uptime Kuma compliance
monitoring — returns 200 + status dict when all five required env vars
are present, 500 with missing var names otherwise.
Runbook: circuitforge-plans/snipe/ebay-webhook-compliance-runbook.md
Kuma monitor: id=19 on heimdall status page (Snipe group)
This commit is contained in:
parent
16cd32b0db
commit
ed6d509a26
1 changed files with 63 additions and 1 deletions
|
|
@ -33,6 +33,7 @@ from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||||
from fastapi import APIRouter, Header, HTTPException, Request
|
from fastapi import APIRouter, Header, HTTPException, Request
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
from app.platforms.ebay.auth import EbayTokenManager
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -40,6 +41,24 @@ router = APIRouter()
|
||||||
|
|
||||||
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
_DB_PATH = Path(os.environ.get("SNIPE_DB", "data/snipe.db"))
|
||||||
|
|
||||||
|
# ── App-level token manager ───────────────────────────────────────────────────
|
||||||
|
# Lazily initialized from env vars; shared across all webhook requests.
|
||||||
|
# The Notification public_key endpoint requires a Bearer app token.
|
||||||
|
_app_token_manager: EbayTokenManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_app_token() -> str | None:
|
||||||
|
"""Return a valid eBay app-level Bearer token, or None if creds are absent."""
|
||||||
|
global _app_token_manager
|
||||||
|
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
|
||||||
|
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return None
|
||||||
|
if _app_token_manager is None:
|
||||||
|
_app_token_manager = EbayTokenManager(client_id, client_secret)
|
||||||
|
return _app_token_manager.get_token()
|
||||||
|
|
||||||
|
|
||||||
# ── Public-key cache ──────────────────────────────────────────────────────────
|
# ── Public-key cache ──────────────────────────────────────────────────────────
|
||||||
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
# eBay key rotation is rare; 1-hour TTL is appropriate.
|
||||||
_KEY_CACHE_TTL = 3600
|
_KEY_CACHE_TTL = 3600
|
||||||
|
|
@ -58,7 +77,14 @@ def _fetch_public_key(kid: str) -> bytes:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
|
|
||||||
key_url = _EBAY_KEY_URL.format(kid=kid)
|
key_url = _EBAY_KEY_URL.format(kid=kid)
|
||||||
resp = requests.get(key_url, timeout=10)
|
headers: dict[str, str] = {}
|
||||||
|
app_token = _get_app_token()
|
||||||
|
if app_token:
|
||||||
|
headers["Authorization"] = f"Bearer {app_token}"
|
||||||
|
else:
|
||||||
|
log.warning("public_key fetch: no app credentials — request will likely fail")
|
||||||
|
|
||||||
|
resp = requests.get(key_url, headers=headers, timeout=10)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
log.error("public key fetch failed: %s %s — body: %s", resp.status_code, key_url, resp.text[:500])
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
@ -68,6 +94,42 @@ def _fetch_public_key(kid: str) -> bytes:
|
||||||
return pem_bytes
|
return pem_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET — webhook health check ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/ebay/webhook-health")
|
||||||
|
def ebay_webhook_health() -> dict:
|
||||||
|
"""Lightweight health check for eBay webhook compliance monitoring.
|
||||||
|
|
||||||
|
Returns 200 + status dict when the webhook is fully configured.
|
||||||
|
Returns 500 when required env vars are missing.
|
||||||
|
Intended for Uptime Kuma or similar uptime monitors.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("EBAY_NOTIFICATION_TOKEN", "")
|
||||||
|
endpoint = os.environ.get("EBAY_NOTIFICATION_ENDPOINT", "")
|
||||||
|
client_id = (os.environ.get("EBAY_APP_ID") or os.environ.get("EBAY_CLIENT_ID", "")).strip()
|
||||||
|
client_secret = (os.environ.get("EBAY_CERT_ID") or os.environ.get("EBAY_CLIENT_SECRET", "")).strip()
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
name for name, val in [
|
||||||
|
("EBAY_NOTIFICATION_TOKEN", token),
|
||||||
|
("EBAY_NOTIFICATION_ENDPOINT", endpoint),
|
||||||
|
("EBAY_APP_ID / EBAY_CLIENT_ID", client_id),
|
||||||
|
("EBAY_CERT_ID / EBAY_CLIENT_SECRET", client_secret),
|
||||||
|
] if not val
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
log.error("ebay_webhook_health: missing config: %s", missing)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Webhook misconfigured — missing: {missing}",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"signature_verification": os.environ.get("EBAY_WEBHOOK_VERIFY_SIGNATURES", "true"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── GET — challenge verification ──────────────────────────────────────────────
|
# ── GET — challenge verification ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/api/ebay/account-deletion")
|
@router.get("/api/ebay/account-deletion")
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue