diff --git a/README.md b/README.md index 81b79b2..47cd777 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,29 @@ export L1_SERVERS=https://celery-artdag.rose-ash.com,https://renderer2.example.c ``` When a user attaches to an L1 server: -1. They're redirected to the L1's `/auth` endpoint with their auth token -2. L1 calls back to L2's `/auth/verify` endpoint to validate the token -3. L1 sets its own local cookie, logging the user in -4. Their attachment is recorded in the `user_renderers` table +1. L2 creates a **scoped token** that only works for that specific L1 +2. User is redirected to the L1's `/auth` endpoint with the scoped token +3. L1 calls back to L2's `/auth/verify` endpoint to validate the token +4. L2 verifies the token scope matches the requesting L1 +5. L1 sets its own local cookie, logging the user in +6. The attachment is recorded in L2's `user_renderers` table -**No shared secrets required**: L1 servers verify tokens by calling L2's `/auth/verify` endpoint. This allows any L1 provider to federate with L2 without needing the JWT secret. +### Security Features -**Authorization**: L1 servers must identify themselves when calling `/auth/verify` by passing their URL. Only servers listed in `L1_SERVERS` are authorized to verify tokens. +**No shared secrets**: L1 servers verify tokens by calling L2's `/auth/verify` endpoint. Any L1 provider can federate without the JWT secret. + +**L1 authorization**: Only servers listed in `L1_SERVERS` can verify tokens. L1 must identify itself in verify requests. + +**Scoped tokens**: Tokens issued for attachment contain an `l1_server` claim. A token scoped to L1-A cannot be used on L1-B, preventing malicious L1s from stealing tokens for use elsewhere. + +**Federated logout**: When a user logs out of L2: +1. L2 revokes the token in its database (`revoked_tokens` table) +2. L2 calls `/auth/revoke` on all attached L1s to revoke their local copies +3. All attachments are removed from `user_renderers` + +Even if a malicious L1 ignores the revoke call, the token will fail verification at L2 because it's in the revocation table. + +**Token revocation on L1**: L1 servers maintain their own Redis-based revocation list and check it on every authenticated request. Users can manage attachments at `/renderers`. diff --git a/auth.py b/auth.py index a3ff749..a56e13c 100644 --- a/auth.py +++ b/auth.py @@ -135,13 +135,15 @@ async def authenticate_user(data_dir: Path, username: str, password: str) -> Opt ) -def create_access_token(username: str, l2_server: str = None) -> Token: +def create_access_token(username: str, l2_server: str = None, l1_server: str = None) -> Token: """Create a JWT access token. Args: username: The username l2_server: The L2 server URL (e.g., https://artdag.rose-ash.com) Required for L1 to verify tokens with the correct L2. + l1_server: Optional L1 server URL to scope the token to. + If set, token only works for this specific L1. """ expires = datetime.now(timezone.utc) + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS) @@ -156,6 +158,10 @@ def create_access_token(username: str, l2_server: str = None) -> Token: if l2_server: payload["l2_server"] = l2_server + # Include l1_server to scope token to specific L1 + if l1_server: + payload["l1_server"] = l1_server + token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM) return Token( @@ -175,6 +181,15 @@ def verify_token(token: str) -> Optional[str]: return None +def get_token_claims(token: str) -> Optional[dict]: + """Decode token and return all claims. Returns None if invalid.""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except JWTError: + return None + + async def get_current_user(data_dir: Path, token: str) -> Optional[User]: """Get current user from token.""" username = verify_token(token) diff --git a/db.py b/db.py index fac7726..bdbb012 100644 --- a/db.py +++ b/db.py @@ -109,6 +109,14 @@ CREATE TABLE IF NOT EXISTS user_renderers ( UNIQUE(username, l1_url) ); +-- Revoked tokens (for federated logout) +CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash VARCHAR(64) PRIMARY KEY, + username VARCHAR(255) NOT NULL, + revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + -- Indexes CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); CREATE INDEX IF NOT EXISTS idx_assets_content_hash ON assets(content_hash); @@ -120,6 +128,7 @@ CREATE INDEX IF NOT EXISTS idx_activities_published ON activities(published DESC CREATE INDEX IF NOT EXISTS idx_activities_anchor ON activities(anchor_root); CREATE INDEX IF NOT EXISTS idx_anchors_created ON anchors(created_at DESC); CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); """ @@ -761,3 +770,43 @@ async def detach_renderer(username: str, l1_url: str) -> bool: username, l1_url ) return "DELETE 1" in result + + +# ============ Token Revocation ============ + +async def revoke_token(token_hash: str, username: str, expires_at) -> bool: + """Revoke a token. Returns True if newly revoked.""" + async with get_connection() as conn: + try: + await conn.execute( + """INSERT INTO revoked_tokens (token_hash, username, expires_at) + VALUES ($1, $2, $3) + ON CONFLICT (token_hash) DO NOTHING""", + token_hash, username, expires_at + ) + return True + except Exception: + return False + + +async def is_token_revoked(token_hash: str) -> bool: + """Check if a token has been revoked.""" + async with get_connection() as conn: + row = await conn.fetchrow( + "SELECT 1 FROM revoked_tokens WHERE token_hash = $1 AND expires_at > NOW()", + token_hash + ) + return row is not None + + +async def cleanup_expired_revocations() -> int: + """Remove expired revocation entries. Returns count removed.""" + async with get_connection() as conn: + result = await conn.execute( + "DELETE FROM revoked_tokens WHERE expires_at < NOW()" + ) + # Extract count from "DELETE N" + try: + return int(result.split()[-1]) + except (ValueError, IndexError): + return 0 diff --git a/server.py b/server.py index 09dfb21..2276675 100644 --- a/server.py +++ b/server.py @@ -38,7 +38,7 @@ import db from auth import ( UserCreate, UserLogin, Token, User, create_user, authenticate_user, create_access_token, - verify_token, get_current_user + verify_token, get_token_claims, get_current_user ) # Configuration @@ -532,12 +532,18 @@ async def ui_register_submit(request: Request): @app.get("/logout") async def logout(request: Request): - """Handle logout - clear cookie, revoke token on attached L1s, and redirect to home.""" + """Handle logout - clear cookie, revoke token on L2 and attached L1s, and redirect to home.""" token = request.cookies.get("auth_token") - username = verify_token(token) if token else None + claims = get_token_claims(token) if token else None + username = claims.get("sub") if claims else None - # Revoke token on all attached L1 renderers - if username and token: + if username and token and claims: + # Revoke token in L2 database (so even if L1 ignores revoke, token won't verify) + token_hash = hashlib.sha256(token.encode()).hexdigest() + expires_at = datetime.fromtimestamp(claims.get("exp", 0), tz=timezone.utc) + await db.revoke_token(token_hash, username, expires_at) + + # Revoke token on all attached L1 renderers attached = await db.get_user_renderers(username) for l1_url in attached: try: @@ -1342,16 +1348,35 @@ async def verify_auth( if not credentials: raise HTTPException(401, "No token provided") + token = credentials.credentials + # Check L1 is authorized l1_normalized = request.l1_server.rstrip("/") authorized = any(l1_normalized == s.rstrip("/") for s in L1_SERVERS) if not authorized: raise HTTPException(403, f"L1 server not authorized: {request.l1_server}") - username = verify_token(credentials.credentials) + # Check if token is revoked (L2-side revocation) + token_hash = hashlib.sha256(token.encode()).hexdigest() + if await db.is_token_revoked(token_hash): + raise HTTPException(401, "Token has been revoked") + + # Verify token and get claims + claims = get_token_claims(token) + if not claims: + raise HTTPException(401, "Invalid token") + + username = claims.get("sub") if not username: raise HTTPException(401, "Invalid token") + # Check token scope - if token is scoped to an L1, it must match + token_l1_server = claims.get("l1_server") + if token_l1_server: + token_l1_normalized = token_l1_server.rstrip("/") + if token_l1_normalized != l1_normalized: + raise HTTPException(403, f"Token is scoped to {token_l1_server}, not {request.l1_server}") + # Record the attachment (L1 successfully verified user's token) await db.attach_renderer(username, l1_normalized) @@ -2879,7 +2904,7 @@ async def renderers_page(request: Request): # Get user's attached renderers attached = await db.get_user_renderers(username) - token = request.cookies.get("auth_token", "") + from urllib.parse import quote # Build renderer list rows = [] @@ -2901,11 +2926,10 @@ async def renderers_page(request: Request): ''' else: status = 'Not attached' - # Attach redirects to L1 with token - attach_url = f"{l1_url}/auth?auth_token={token}" + # Attach via endpoint that creates scoped token (not raw token in URL) + attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}" action = f''' - + Attach ''' @@ -2934,6 +2958,30 @@ async def renderers_page(request: Request): return HTMLResponse(base_html("Renderers", content, username)) +@app.get("/renderers/attach") +async def attach_renderer_redirect(request: Request, l1_url: str): + """Create a scoped token and redirect to L1 for attachment.""" + username = get_user_from_cookie(request) + if not username: + return RedirectResponse(url="/login", status_code=302) + + # Verify L1 is in our allowed list + l1_normalized = l1_url.rstrip("/") + if not any(l1_normalized == s.rstrip("/") for s in L1_SERVERS): + raise HTTPException(403, f"L1 server not authorized: {l1_url}") + + # Create a scoped token that only works for this specific L1 + scoped_token = create_access_token( + username, + l2_server=f"https://{DOMAIN}", + l1_server=l1_normalized + ) + + # Redirect to L1 with scoped token + redirect_url = f"{l1_normalized}/auth?auth_token={scoped_token.access_token}" + return RedirectResponse(url=redirect_url, status_code=302) + + @app.post("/renderers/detach", response_class=HTMLResponse) async def detach_renderer(request: Request): """Detach from an L1 renderer.""" @@ -2946,10 +2994,10 @@ async def detach_renderer(request: Request): await db.detach_renderer(username, l1_url) - # Return updated row + # Return updated row with link to attach endpoint (not raw token) display_name = l1_url.replace("https://", "").replace("http://", "") - token = request.cookies.get("auth_token", "") - attach_url = f"{l1_url}/auth?auth_token={token}" + from urllib.parse import quote + attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}" row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-") return HTMLResponse(f''' @@ -2960,8 +3008,7 @@ async def detach_renderer(request: Request):
Not attached - + Attach