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.
|
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"])
|
||||||
|
|||||||
@@ -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
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
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user