Add scoped tokens, L2-side revocation, and security docs

Security improvements:
- Tokens now include optional l1_server claim for scoping
- /auth/verify checks token scope matches requesting L1
- L2 maintains revoked_tokens table - even if L1 ignores revoke, token fails
- Logout revokes token in L2 db before notifying L1s
- /renderers/attach creates scoped tokens (not embedded in HTML)
- Add get_token_claims() to auth.py

Database:
- Add revoked_tokens table with token_hash, username, expires_at
- Add db functions: revoke_token, is_token_revoked, cleanup_expired_revocations

Documentation:
- Document security features in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-09 18:21:13 +00:00
parent 4351c97ce0
commit 5ebfdac887
4 changed files with 149 additions and 23 deletions

View File

@@ -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: When a user attaches to an L1 server:
1. They're redirected to the L1's `/auth` endpoint with their auth token 1. L2 creates a **scoped token** that only works for that specific L1
2. L1 calls back to L2's `/auth/verify` endpoint to validate the token 2. User is redirected to the L1's `/auth` endpoint with the scoped token
3. L1 sets its own local cookie, logging the user in 3. L1 calls back to L2's `/auth/verify` endpoint to validate the token
4. Their attachment is recorded in the `user_renderers` table 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`. Users can manage attachments at `/renderers`.

17
auth.py
View File

@@ -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. """Create a JWT access token.
Args: Args:
username: The username username: The username
l2_server: The L2 server URL (e.g., https://artdag.rose-ash.com) l2_server: The L2 server URL (e.g., https://artdag.rose-ash.com)
Required for L1 to verify tokens with the correct L2. 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) 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: if l2_server:
payload["l2_server"] = 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) token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
return Token( return Token(
@@ -175,6 +181,15 @@ def verify_token(token: str) -> Optional[str]:
return None 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]: async def get_current_user(data_dir: Path, token: str) -> Optional[User]:
"""Get current user from token.""" """Get current user from token."""
username = verify_token(token) username = verify_token(token)

49
db.py
View File

@@ -109,6 +109,14 @@ CREATE TABLE IF NOT EXISTS user_renderers (
UNIQUE(username, l1_url) 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 -- Indexes
CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); 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); 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_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_anchors_created ON anchors(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_followers_username ON followers(username); 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 username, l1_url
) )
return "DELETE 1" in result 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

View File

@@ -38,7 +38,7 @@ import db
from auth import ( from auth import (
UserCreate, UserLogin, Token, User, UserCreate, UserLogin, Token, User,
create_user, authenticate_user, create_access_token, create_user, authenticate_user, create_access_token,
verify_token, get_current_user verify_token, get_token_claims, get_current_user
) )
# Configuration # Configuration
@@ -532,12 +532,18 @@ async def ui_register_submit(request: Request):
@app.get("/logout") @app.get("/logout")
async def logout(request: Request): 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") 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 and claims:
if username and token: # 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) attached = await db.get_user_renderers(username)
for l1_url in attached: for l1_url in attached:
try: try:
@@ -1342,16 +1348,35 @@ async def verify_auth(
if not credentials: if not credentials:
raise HTTPException(401, "No token provided") raise HTTPException(401, "No token provided")
token = credentials.credentials
# Check L1 is authorized # Check L1 is authorized
l1_normalized = request.l1_server.rstrip("/") l1_normalized = request.l1_server.rstrip("/")
authorized = any(l1_normalized == s.rstrip("/") for s in L1_SERVERS) authorized = any(l1_normalized == s.rstrip("/") for s in L1_SERVERS)
if not authorized: if not authorized:
raise HTTPException(403, f"L1 server not authorized: {request.l1_server}") 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: if not username:
raise HTTPException(401, "Invalid token") 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) # Record the attachment (L1 successfully verified user's token)
await db.attach_renderer(username, l1_normalized) await db.attach_renderer(username, l1_normalized)
@@ -2879,7 +2904,7 @@ async def renderers_page(request: Request):
# Get user's attached renderers # Get user's attached renderers
attached = await db.get_user_renderers(username) attached = await db.get_user_renderers(username)
token = request.cookies.get("auth_token", "") from urllib.parse import quote
# Build renderer list # Build renderer list
rows = [] rows = []
@@ -2901,11 +2926,10 @@ async def renderers_page(request: Request):
''' '''
else: else:
status = '<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>' status = '<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>'
# Attach redirects to L1 with token # Attach via endpoint that creates scoped token (not raw token in URL)
attach_url = f"{l1_url}/auth?auth_token={token}" attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
action = f''' action = f'''
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors" <a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
onclick="setTimeout(() => location.reload(), 2000)">
Attach Attach
</a> </a>
''' '''
@@ -2934,6 +2958,30 @@ async def renderers_page(request: Request):
return HTMLResponse(base_html("Renderers", content, username)) 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) @app.post("/renderers/detach", response_class=HTMLResponse)
async def detach_renderer(request: Request): async def detach_renderer(request: Request):
"""Detach from an L1 renderer.""" """Detach from an L1 renderer."""
@@ -2946,10 +2994,10 @@ async def detach_renderer(request: Request):
await db.detach_renderer(username, l1_url) 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://", "") display_name = l1_url.replace("https://", "").replace("http://", "")
token = request.cookies.get("auth_token", "") from urllib.parse import quote
attach_url = f"{l1_url}/auth?auth_token={token}" attach_url = f"/renderers/attach?l1_url={quote(l1_url, safe='')}"
row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-") row_id = l1_url.replace("://", "-").replace("/", "-").replace(".", "-")
return HTMLResponse(f''' return HTMLResponse(f'''
@@ -2960,8 +3008,7 @@ async def detach_renderer(request: Request):
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span> <span class="px-2 py-1 bg-gray-600 text-gray-300 text-xs font-medium rounded-full">Not attached</span>
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors" <a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
onclick="setTimeout(() => location.reload(), 2000)">
Attach Attach
</a> </a>
</div> </div>