Add AP inbox endpoint + device auth signaling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
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:
@@ -4,6 +4,7 @@ Art-DAG L1 Server Application Factory.
|
||||
Creates and configures the FastAPI application with all routers and middleware.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
@@ -18,8 +19,10 @@ from artdag_common.middleware.auth import get_user_from_cookie
|
||||
from .config import settings
|
||||
|
||||
# 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
|
||||
_DEVICE_COOKIE = "artdag_did"
|
||||
_DEVICE_COOKIE_MAX_AGE = 30 * 24 * 3600 # 30 days
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -46,6 +49,30 @@ def create_app() -> FastAPI:
|
||||
async def shutdown():
|
||||
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
|
||||
@app.middleware("http")
|
||||
async def silent_auth_check(request: Request, call_next):
|
||||
@@ -65,7 +92,24 @@ def create_app() -> FastAPI:
|
||||
pnone_at = request.cookies.get("pnone_at")
|
||||
if pnone_at:
|
||||
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)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
@@ -94,11 +138,12 @@ def create_app() -> FastAPI:
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
|
||||
# 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)
|
||||
app.include_router(home.router, tags=["home"])
|
||||
app.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
app.include_router(inbox.router, tags=["inbox"])
|
||||
|
||||
# Feature routers
|
||||
app.include_router(storage.router, prefix="/storage", tags=["storage"])
|
||||
|
||||
@@ -40,10 +40,12 @@ async def login(request: Request):
|
||||
signer = _get_signer()
|
||||
state_payload = signer.dumps({"state": state, "next": next_url, "prompt": prompt})
|
||||
|
||||
device_id = getattr(request.state, "device_id", "")
|
||||
authorize_url = (
|
||||
f"{settings.oauth_authorize_url}"
|
||||
f"?client_id={settings.oauth_client_id}"
|
||||
f"&redirect_uri={settings.oauth_redirect_uri}"
|
||||
f"&device_id={device_id}"
|
||||
f"&state={state}"
|
||||
)
|
||||
if prompt:
|
||||
@@ -67,6 +69,12 @@ async def callback(request: Request):
|
||||
code = request.query_params.get("code", "")
|
||||
state = request.query_params.get("state", "")
|
||||
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
|
||||
state_cookie = request.cookies.get("oauth_state", "")
|
||||
@@ -77,7 +85,6 @@ async def callback(request: Request):
|
||||
payload = {}
|
||||
|
||||
next_url = payload.get("next", "/")
|
||||
was_silent = payload.get("prompt") == "none"
|
||||
|
||||
# Handle prompt=none rejection (user not logged in on account)
|
||||
if error == "login_required":
|
||||
@@ -92,6 +99,16 @@ async def callback(request: Request):
|
||||
samesite="lax",
|
||||
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
|
||||
|
||||
# Normal callback — validate state + code
|
||||
|
||||
125
app/routers/inbox.py
Normal file
125
app/routers/inbox.py
Normal 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
0
app/utils/__init__.py
Normal file
84
app/utils/http_signatures.py
Normal file
84
app/utils/http_signatures.py
Normal 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
|
||||
@@ -3,6 +3,7 @@ redis>=5.0.0
|
||||
requests>=2.31.0
|
||||
httpx>=0.27.0
|
||||
itsdangerous>=2.0
|
||||
cryptography>=41.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
|
||||
Reference in New Issue
Block a user