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:
27
README.md
27
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`.
|
||||
|
||||
|
||||
17
auth.py
17
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)
|
||||
|
||||
49
db.py
49
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
|
||||
|
||||
79
server.py
79
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 = '<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>
|
||||
|
||||
Reference in New Issue
Block a user