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

49
db.py
View File

@@ -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