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:
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`.

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.
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)

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

View File

@@ -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 = '<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_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'''
<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)">
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
Attach
</a>
'''
@@ -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):
</div>
<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>
<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)">
<a href="{attach_url}" class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors">
Attach
</a>
</div>