diff --git a/app/__init__.py b/app/__init__.py index 2e3caf4..6d4b202 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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"]) diff --git a/app/routers/auth.py b/app/routers/auth.py index 4d78d47..ee569f2 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 diff --git a/app/routers/inbox.py b/app/routers/inbox.py new file mode 100644 index 0000000..d6fa37c --- /dev/null +++ b/app/routers/inbox.py @@ -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) diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/http_signatures.py b/app/utils/http_signatures.py new file mode 100644 index 0000000..da1f105 --- /dev/null +++ b/app/utils/http_signatures.py @@ -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 diff --git a/requirements.txt b/requirements.txt index ce5b1bd..deab545 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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