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>
413 lines
11 KiB
Python
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
|