Files
StarPunk/starpunk/tokens.py
Phil Skentelbery 3b41029c75 feat: Implement secure token management for Micropub
Implements token security and management as specified in ADR-029:

Database Changes (BREAKING):
- Add secure tokens table with SHA256 hashed storage
- Add authorization_codes table for IndieAuth token exchange
- Drop old insecure tokens table (invalidates existing tokens)
- Update SCHEMA_SQL to match post-migration state

Token Management (starpunk/tokens.py):
- Generate cryptographically secure tokens
- Hash tokens with SHA256 for secure storage
- Create and verify access tokens
- Create and exchange authorization codes
- PKCE support (optional but recommended)
- Scope validation (V1: only 'create' scope)
- Token expiry and revocation support

Testing:
- Comprehensive test suite for all token operations
- Test authorization code replay protection
- Test PKCE validation
- Test parameter validation
- Test token expiry

Security:
- Tokens never stored in plain text
- Authorization codes single-use with replay protection
- Optional PKCE for enhanced security
- Proper UTC datetime handling for expiry

Related:
- ADR-029: Micropub IndieAuth Integration Strategy
- Migration 002: Secure tokens and authorization codes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 11:52:09 -07:00

413 lines
11 KiB
Python

"""
Token management for Micropub IndieAuth integration
Handles:
- Access token generation and verification
- Authorization code generation and exchange
- Token hashing for secure storage (SHA256)
- Scope validation
- Token expiry management
Security:
- Tokens stored as SHA256 hashes (never plain text)
- Authorization codes use single-use pattern with replay protection
- Optional PKCE support for enhanced security
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from flask import current_app
# V1 supported scopes
SUPPORTED_SCOPES = ["create"]
DEFAULT_SCOPE = "create"
# Token and code expiry defaults
TOKEN_EXPIRY_DAYS = 90
AUTH_CODE_EXPIRY_MINUTES = 10
class TokenError(Exception):
"""Base exception for token-related errors"""
pass
class InvalidTokenError(TokenError):
"""Raised when token is invalid or expired"""
pass
class InvalidAuthorizationCodeError(TokenError):
"""Raised when authorization code is invalid, expired, or already used"""
pass
def generate_token() -> str:
"""
Generate a cryptographically secure random token
Returns:
URL-safe base64-encoded random token (43 characters)
"""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""
Generate SHA256 hash of token for secure storage
Args:
token: Plain text token
Returns:
Hexadecimal SHA256 hash
"""
return hashlib.sha256(token.encode()).hexdigest()
def create_access_token(me: str, client_id: str, scope: str) -> str:
"""
Create and store an access token in the database
Args:
me: User's identity URL
client_id: Client application URL
scope: Space-separated list of scopes
Returns:
Plain text access token (return to client, never logged or stored)
Raises:
TokenError: If token creation fails
"""
# Generate token
token = generate_token()
token_hash_value = hash_token(token)
# Calculate expiry
# Use UTC to match SQLite's datetime('now') which returns UTC
expires_at = (datetime.utcnow() + timedelta(days=TOKEN_EXPIRY_DAYS)).strftime('%Y-%m-%d %H:%M:%S')
# Store in database
from starpunk.database import get_db
try:
db = get_db(current_app)
db.execute("""
INSERT INTO tokens (token_hash, me, client_id, scope, expires_at)
VALUES (?, ?, ?, ?, ?)
""", (token_hash_value, me, client_id, scope, expires_at))
db.commit()
current_app.logger.info(
f"Created access token for client_id={client_id}, scope={scope}"
)
return token
except Exception as e:
current_app.logger.error(f"Failed to create access token: {e}")
raise TokenError(f"Failed to create access token: {e}")
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""
Verify an access token and return token information
Args:
token: Plain text token to verify
Returns:
Dictionary with token info: {me, client_id, scope}
None if token is invalid, expired, or revoked
"""
if not token:
return None
# Hash the token for lookup
token_hash_value = hash_token(token)
from starpunk.database import get_db
try:
db = get_db(current_app)
row = db.execute("""
SELECT me, client_id, scope, id
FROM tokens
WHERE token_hash = ?
AND expires_at > datetime('now')
AND revoked_at IS NULL
""", (token_hash_value,)).fetchone()
if row:
# Update last_used_at
db.execute("""
UPDATE tokens
SET last_used_at = datetime('now')
WHERE id = ?
""", (row['id'],))
db.commit()
return {
'me': row['me'],
'client_id': row['client_id'],
'scope': row['scope']
}
return None
except Exception as e:
current_app.logger.error(f"Token verification failed: {e}")
return None
def revoke_token(token: str) -> bool:
"""
Revoke an access token (soft deletion)
Args:
token: Plain text token to revoke
Returns:
True if token was revoked, False if not found
"""
token_hash_value = hash_token(token)
from starpunk.database import get_db
try:
db = get_db(current_app)
cursor = db.execute("""
UPDATE tokens
SET revoked_at = datetime('now')
WHERE token_hash = ?
AND revoked_at IS NULL
""", (token_hash_value,))
db.commit()
return cursor.rowcount > 0
except Exception as e:
current_app.logger.error(f"Token revocation failed: {e}")
return False
def create_authorization_code(
me: str,
client_id: str,
redirect_uri: str,
scope: str = "",
state: Optional[str] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None
) -> str:
"""
Create and store an authorization code for token exchange
Args:
me: User's identity URL
client_id: Client application URL
redirect_uri: Client's redirect URI (must match during exchange)
scope: Space-separated list of requested scopes (can be empty)
state: Client's state parameter (optional)
code_challenge: PKCE code challenge (optional)
code_challenge_method: PKCE method, typically 'S256' (optional)
Returns:
Plain text authorization code (return to client)
Raises:
TokenError: If code creation fails
"""
# Generate authorization code
code = generate_token()
code_hash_value = hash_token(code)
# Calculate expiry (short-lived)
# Use UTC to match SQLite's datetime('now') which returns UTC
expires_at = (datetime.utcnow() + timedelta(minutes=AUTH_CODE_EXPIRY_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
# Store in database
from starpunk.database import get_db
try:
db = get_db(current_app)
db.execute("""
INSERT INTO authorization_codes (
code_hash, me, client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
code_hash_value, me, client_id, redirect_uri, scope, state,
code_challenge, code_challenge_method, expires_at
))
db.commit()
current_app.logger.info(
f"Created authorization code for client_id={client_id}, scope={scope}"
)
return code
except Exception as e:
current_app.logger.error(f"Failed to create authorization code: {e}")
raise TokenError(f"Failed to create authorization code: {e}")
def exchange_authorization_code(
code: str,
client_id: str,
redirect_uri: str,
me: str,
code_verifier: Optional[str] = None
) -> Dict[str, Any]:
"""
Exchange authorization code for access token
Args:
code: Authorization code to exchange
client_id: Client application URL (must match original request)
redirect_uri: Redirect URI (must match original request)
me: User's identity URL (must match original request)
code_verifier: PKCE verifier (required if code_challenge was provided)
Returns:
Dictionary with: {me, client_id, scope}
Raises:
InvalidAuthorizationCodeError: If code is invalid, expired, used, or validation fails
"""
if not code:
raise InvalidAuthorizationCodeError("No authorization code provided")
code_hash_value = hash_token(code)
from starpunk.database import get_db
try:
db = get_db(current_app)
# Look up authorization code
row = db.execute("""
SELECT me, client_id, redirect_uri, scope, code_challenge,
code_challenge_method, used_at
FROM authorization_codes
WHERE code_hash = ?
AND expires_at > datetime('now')
""", (code_hash_value,)).fetchone()
if not row:
raise InvalidAuthorizationCodeError(
"Authorization code is invalid or expired"
)
# Check if already used (prevent replay attacks)
if row['used_at']:
raise InvalidAuthorizationCodeError(
"Authorization code has already been used"
)
# Validate parameters match original authorization request
if row['client_id'] != client_id:
raise InvalidAuthorizationCodeError(
"client_id does not match authorization request"
)
if row['redirect_uri'] != redirect_uri:
raise InvalidAuthorizationCodeError(
"redirect_uri does not match authorization request"
)
if row['me'] != me:
raise InvalidAuthorizationCodeError(
"me parameter does not match authorization request"
)
# Validate PKCE if code_challenge was provided
if row['code_challenge']:
if not code_verifier:
raise InvalidAuthorizationCodeError(
"code_verifier required (PKCE was used during authorization)"
)
# Verify PKCE challenge
if row['code_challenge_method'] == 'S256':
# SHA256 hash of verifier
computed_challenge = hashlib.sha256(
code_verifier.encode()
).hexdigest()
else:
# Plain (not recommended, but spec allows it)
computed_challenge = code_verifier
if computed_challenge != row['code_challenge']:
raise InvalidAuthorizationCodeError(
"code_verifier does not match code_challenge"
)
# Mark code as used
db.execute("""
UPDATE authorization_codes
SET used_at = datetime('now')
WHERE code_hash = ?
""", (code_hash_value,))
db.commit()
# Return authorization info for token creation
return {
'me': row['me'],
'client_id': row['client_id'],
'scope': row['scope']
}
except InvalidAuthorizationCodeError:
# Re-raise validation errors
raise
except Exception as e:
current_app.logger.error(f"Authorization code exchange failed: {e}")
raise InvalidAuthorizationCodeError(f"Code exchange failed: {e}")
def validate_scope(requested_scope: str) -> str:
"""
Validate and filter requested scopes to supported ones
Args:
requested_scope: Space-separated list of requested scopes
Returns:
Space-separated list of valid scopes (may be empty)
"""
if not requested_scope:
return ""
requested = set(requested_scope.split())
supported = set(SUPPORTED_SCOPES)
valid_scopes = requested & supported
return " ".join(sorted(valid_scopes)) if valid_scopes else ""
def check_scope(required: str, granted: str) -> bool:
"""
Check if granted scopes include required scope
Args:
required: Required scope (single scope string)
granted: Granted scopes (space-separated string)
Returns:
True if required scope is in granted scopes
"""
if not granted:
# IndieAuth spec: no scope means no access
return False
granted_scopes = set(granted.split())
return required in granted_scopes