All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
- POST /inbox with HTTP Signature verification - Device ID cookie tracking + adoption from account - Silent auth checks local Redis for did_auth signals - Replaces shared-Redis coupling with AP activity delivery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.9 KiB
Python
126 lines
3.9 KiB
Python
"""AP-style inbox endpoint for receiving signed activities from the coop.
|
|
|
|
POST /inbox — verify HTTP Signature, dispatch by activity type.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from ..dependencies import get_redis_client
|
|
from ..utils.http_signatures import verify_request_signature, parse_key_id
|
|
|
|
log = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
# Cache fetched public keys in Redis for 24 hours
|
|
_KEY_CACHE_TTL = 86400
|
|
|
|
|
|
async def _fetch_actor_public_key(actor_url: str) -> str | None:
|
|
"""Fetch an actor's public key, with Redis caching."""
|
|
redis = get_redis_client()
|
|
cache_key = f"actor_pubkey:{actor_url}"
|
|
|
|
# Check cache
|
|
cached = redis.get(cache_key)
|
|
if cached:
|
|
return cached
|
|
|
|
# Fetch actor JSON
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
actor_url,
|
|
headers={"Accept": "application/activity+json, application/ld+json"},
|
|
)
|
|
if resp.status_code != 200:
|
|
log.warning("Failed to fetch actor %s: %d", actor_url, resp.status_code)
|
|
return None
|
|
data = resp.json()
|
|
except Exception:
|
|
log.warning("Error fetching actor %s", actor_url, exc_info=True)
|
|
return None
|
|
|
|
pub_key_pem = (data.get("publicKey") or {}).get("publicKeyPem")
|
|
if not pub_key_pem:
|
|
log.warning("No publicKey in actor %s", actor_url)
|
|
return None
|
|
|
|
# Cache it
|
|
redis.set(cache_key, pub_key_pem, ex=_KEY_CACHE_TTL)
|
|
return pub_key_pem
|
|
|
|
|
|
@router.post("/inbox")
|
|
async def inbox(request: Request):
|
|
"""Receive signed AP activities from the coop platform."""
|
|
sig_header = request.headers.get("signature", "")
|
|
if not sig_header:
|
|
return JSONResponse({"error": "missing signature"}, status_code=401)
|
|
|
|
# Read body
|
|
body = await request.body()
|
|
|
|
# Verify HTTP Signature
|
|
actor_url = parse_key_id(sig_header)
|
|
if not actor_url:
|
|
return JSONResponse({"error": "invalid keyId"}, status_code=401)
|
|
|
|
pub_key = await _fetch_actor_public_key(actor_url)
|
|
if not pub_key:
|
|
return JSONResponse({"error": "could not fetch public key"}, status_code=401)
|
|
|
|
req_headers = dict(request.headers)
|
|
path = request.url.path
|
|
valid = verify_request_signature(
|
|
public_key_pem=pub_key,
|
|
signature_header=sig_header,
|
|
method="POST",
|
|
path=path,
|
|
headers=req_headers,
|
|
)
|
|
if not valid:
|
|
log.warning("Invalid signature from %s", actor_url)
|
|
return JSONResponse({"error": "invalid signature"}, status_code=401)
|
|
|
|
# Parse and dispatch
|
|
try:
|
|
activity = await request.json()
|
|
except Exception:
|
|
return JSONResponse({"error": "invalid json"}, status_code=400)
|
|
|
|
activity_type = activity.get("type", "")
|
|
log.info("Inbox received: %s from %s", activity_type, actor_url)
|
|
|
|
if activity_type == "rose:DeviceAuth":
|
|
_handle_device_auth(activity)
|
|
|
|
# Always 202 — AP convention
|
|
return JSONResponse({"status": "accepted"}, status_code=202)
|
|
|
|
|
|
def _handle_device_auth(activity: dict) -> None:
|
|
"""Set or delete did_auth:{device_id} in local Redis."""
|
|
obj = activity.get("object", {})
|
|
device_id = obj.get("device_id", "")
|
|
action = obj.get("action", "")
|
|
|
|
if not device_id:
|
|
log.warning("rose:DeviceAuth missing device_id")
|
|
return
|
|
|
|
redis = get_redis_client()
|
|
if action == "login":
|
|
redis.set(f"did_auth:{device_id}", str(time.time()), ex=30 * 24 * 3600)
|
|
log.info("did_auth set for device %s...", device_id[:16])
|
|
elif action == "logout":
|
|
redis.delete(f"did_auth:{device_id}")
|
|
log.info("did_auth cleared for device %s...", device_id[:16])
|
|
else:
|
|
log.warning("rose:DeviceAuth unknown action: %s", action)
|