Add AP inbox endpoint + device auth signaling
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>
This commit is contained in:
giles
2026-02-24 00:41:33 +00:00
parent ab3b6b672d
commit b294fd0695
6 changed files with 276 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ Art-DAG L1 Server Application Factory.
Creates and configures the FastAPI application with all routers and middleware. Creates and configures the FastAPI application with all routers and middleware.
""" """
import secrets
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -18,8 +19,10 @@ from artdag_common.middleware.auth import get_user_from_cookie
from .config import settings from .config import settings
# Paths that should never trigger a silent auth check # Paths that should never trigger a silent auth check
_SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/") _SKIP_PREFIXES = ("/auth/", "/static/", "/api/", "/ipfs/", "/download/", "/inbox")
_SILENT_CHECK_COOLDOWN = 300 # 5 minutes _SILENT_CHECK_COOLDOWN = 300 # 5 minutes
_DEVICE_COOKIE = "artdag_did"
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
def create_app() -> FastAPI: def create_app() -> FastAPI:
@@ -46,6 +49,30 @@ def create_app() -> FastAPI:
async def shutdown(): async def shutdown():
await close_db() await close_db()
# Device ID middleware — track browser identity across domains
@app.middleware("http")
async def device_id_middleware(request: Request, call_next):
did = request.cookies.get(_DEVICE_COOKIE)
if did:
request.state.device_id = did
request.state._new_device_id = False
else:
request.state.device_id = secrets.token_urlsafe(32)
request.state._new_device_id = True
response = await call_next(request)
if getattr(request.state, "_new_device_id", False):
response.set_cookie(
key=_DEVICE_COOKIE,
value=request.state.device_id,
max_age=_DEVICE_COOKIE_MAX_AGE,
httponly=True,
samesite="lax",
secure=True,
)
return response
# Silent auth check — auto-login via prompt=none OAuth # Silent auth check — auto-login via prompt=none OAuth
@app.middleware("http") @app.middleware("http")
async def silent_auth_check(request: Request, call_next): async def silent_auth_check(request: Request, call_next):
@@ -65,7 +92,24 @@ def create_app() -> FastAPI:
pnone_at = request.cookies.get("pnone_at") pnone_at = request.cookies.get("pnone_at")
if pnone_at: if pnone_at:
try: try:
if (time.time() - float(pnone_at)) < _SILENT_CHECK_COOLDOWN: pnone_ts = float(pnone_at)
if (time.time() - pnone_ts) < _SILENT_CHECK_COOLDOWN:
# But first check if account signalled a login via inbox delivery
device_id = getattr(request.state, "device_id", None)
if device_id:
try:
from .dependencies import get_redis_client
r = get_redis_client()
auth_ts = r.get(f"did_auth:{device_id}")
if auth_ts and float(auth_ts) > pnone_ts:
# Login happened since our last check — retry
current_url = str(request.url)
return RedirectResponse(
url=f"/auth/login?prompt=none&next={quote(current_url, safe='')}",
status_code=302,
)
except Exception:
pass
return await call_next(request) return await call_next(request)
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@@ -94,11 +138,12 @@ def create_app() -> FastAPI:
return JSONResponse({"detail": "Not found"}, status_code=404) return JSONResponse({"detail": "Not found"}, status_code=404)
# Include routers # Include routers
from .routers import auth, storage, api, recipes, cache, runs, home, effects from .routers import auth, storage, api, recipes, cache, runs, home, effects, inbox
# Home and auth routers (root level) # Home and auth routers (root level)
app.include_router(home.router, tags=["home"]) app.include_router(home.router, tags=["home"])
app.include_router(auth.router, prefix="/auth", tags=["auth"]) app.include_router(auth.router, prefix="/auth", tags=["auth"])
app.include_router(inbox.router, tags=["inbox"])
# Feature routers # Feature routers
app.include_router(storage.router, prefix="/storage", tags=["storage"]) app.include_router(storage.router, prefix="/storage", tags=["storage"])

View File

@@ -40,10 +40,12 @@ async def login(request: Request):
signer = _get_signer() signer = _get_signer()
state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt}) state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt})
device_id = getattr(request.state, "device_id", "")
authorize_url = ( authorize_url = (
f"{settings.oauth_authorize_url}" f"{settings.oauth_authorize_url}"
f"?client_id={settings.oauth_client_id}" f"?client_id={settings.oauth_client_id}"
f"&redirect_uri={settings.oauth_redirect_uri}" f"&redirect_uri={settings.oauth_redirect_uri}"
f"&device_id={device_id}"
f"&state={state}" f"&state={state}"
) )
if prompt: if prompt:
@@ -67,6 +69,12 @@ async def callback(request: Request):
code = request.query_params.get("code", "") code = request.query_params.get("code", "")
state = request.query_params.get("state", "") state = request.query_params.get("state", "")
error = request.query_params.get("error", "") error = request.query_params.get("error", "")
account_did = request.query_params.get("account_did", "")
# Adopt account's device ID as our own (one identity across all apps)
if account_did:
request.state.device_id = account_did
request.state._new_device_id = True # device_id middleware will set cookie
# Recover state from signed cookie # Recover state from signed cookie
state_cookie = request.cookies.get("oauth_state", "") state_cookie = request.cookies.get("oauth_state", "")
@@ -77,7 +85,6 @@ async def callback(request: Request):
payload = {} payload = {}
next_url = payload.get("next", "/") next_url = payload.get("next", "/")
was_silent = payload.get("prompt") == "none"
# Handle prompt=none rejection (user not logged in on account) # Handle prompt=none rejection (user not logged in on account)
if error == "login_required": if error == "login_required":
@@ -92,6 +99,16 @@ async def callback(request: Request):
samesite="lax", samesite="lax",
secure=True, secure=True,
) )
# Set device cookie if adopted
if account_did:
response.set_cookie(
key="artdag_did",
value=account_did,
max_age=30 * 24 * 3600,
httponly=True,
samesite="lax",
secure=True,
)
return response return response
# Normal callback — validate state + code # Normal callback — validate state + code

125
app/routers/inbox.py Normal file
View File

@@ -0,0 +1,125 @@
"""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)

0
app/utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,84 @@
"""HTTP Signature verification for incoming AP-style inbox requests.
Implements the same RSA-SHA256 / PKCS1v15 scheme used by the coop's
shared/utils/http_signatures.py, but only the verification side.
"""
from __future__ import annotations
import base64
import re
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify_request_signature(
public_key_pem: str,
signature_header: str,
method: str,
path: str,
headers: dict[str, str],
) -> bool:
"""Verify an incoming HTTP Signature.
Args:
public_key_pem: PEM-encoded public key of the sender.
signature_header: Value of the ``Signature`` header.
method: HTTP method (GET, POST, etc.).
path: Request path (e.g. ``/inbox``).
headers: All request headers (case-insensitive keys).
Returns:
True if the signature is valid.
"""
parts = _parse_signature_header(signature_header)
signed_headers = parts.get("headers", "date").split()
signature_b64 = parts.get("signature", "")
# Reconstruct the signed string
lc_headers = {k.lower(): v for k, v in headers.items()}
lines: list[str] = []
for h in signed_headers:
if h == "(request-target)":
lines.append(f"(request-target): {method.lower()} {path}")
else:
lines.append(f"{h}: {lc_headers.get(h, '')}")
signed_string = "\n".join(lines)
public_key = serialization.load_pem_public_key(public_key_pem.encode())
try:
public_key.verify(
base64.b64decode(signature_b64),
signed_string.encode(),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception:
return False
def parse_key_id(signature_header: str) -> str:
"""Extract the keyId from a Signature header.
keyId is typically ``https://domain/users/username#main-key``.
Returns the actor URL (strips ``#main-key``).
"""
parts = _parse_signature_header(signature_header)
key_id = parts.get("keyId", "")
return re.sub(r"#.*$", "", key_id)
def _parse_signature_header(header: str) -> dict[str, str]:
"""Parse a Signature header into its component parts."""
parts: dict[str, str] = {}
for part in header.split(","):
part = part.strip()
eq = part.find("=")
if eq < 0:
continue
key = part[:eq]
val = part[eq + 1:].strip('"')
parts[key] = val
return parts

View File

@@ -3,6 +3,7 @@ redis>=5.0.0
requests>=2.31.0 requests>=2.31.0
httpx>=0.27.0 httpx>=0.27.0
itsdangerous>=2.0 itsdangerous>=2.0
cryptography>=41.0
fastapi>=0.109.0 fastapi>=0.109.0
uvicorn>=0.27.0 uvicorn>=0.27.0
python-multipart>=0.0.6 python-multipart>=0.0.6