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

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