Completed all remaining phases of ADR-030 IndieAuth provider removal. StarPunk no longer acts as an authorization server - all IndieAuth operations delegated to external providers. Phase 2 - Remove Token Issuance: - Deleted /auth/token endpoint - Removed token_endpoint() function from routes/auth.py - Deleted tests/test_routes_token.py Phase 3 - Remove Token Storage: - Deleted starpunk/tokens.py module entirely - Created migration 004 to drop tokens and authorization_codes tables - Deleted tests/test_tokens.py - Removed all internal token CRUD operations Phase 4 - External Token Verification: - Created starpunk/auth_external.py module - Implemented verify_external_token() for external IndieAuth providers - Updated Micropub endpoint to use external verification - Added TOKEN_ENDPOINT configuration - Updated all Micropub tests to mock external verification - HTTP timeout protection (5s) for external requests Additional Changes: - Created migration 003 to remove code_verifier from auth_state - Fixed 5 migration tests that referenced obsolete code_verifier column - Updated 11 Micropub tests for external verification - Fixed test fixture and app context issues - All 501 tests passing Breaking Changes: - Micropub clients must use external IndieAuth providers - TOKEN_ENDPOINT configuration now required - Existing internal tokens invalid (tables dropped) Migration Impact: - Simpler codebase: -500 lines of code - Fewer database tables: -2 tables (tokens, authorization_codes) - More secure: External providers handle token security - More maintainable: Less authentication code to maintain Standards Compliance: - W3C IndieAuth specification - OAuth 2.0 Bearer token authentication - IndieWeb principle: delegate to external services Related: - ADR-030: IndieAuth Provider Removal Strategy - ADR-050: Remove Custom IndieAuth Server - Migration 003: Remove code_verifier from auth_state - Migration 004: Drop tokens and authorization_codes tables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
154 lines
4.3 KiB
Python
154 lines
4.3 KiB
Python
"""
|
|
External IndieAuth Token Verification for StarPunk
|
|
|
|
This module handles verification of bearer tokens issued by external
|
|
IndieAuth providers. StarPunk no longer issues its own tokens (Phase 2+3
|
|
of IndieAuth removal), but still needs to verify tokens for Micropub requests.
|
|
|
|
Functions:
|
|
verify_external_token: Verify token with external IndieAuth provider
|
|
check_scope: Verify token has required scope
|
|
|
|
Configuration (via Flask app.config):
|
|
TOKEN_ENDPOINT: External token endpoint URL for verification
|
|
ADMIN_ME: Expected 'me' value in token (site owner identity)
|
|
|
|
ADR: ADR-030 IndieAuth Provider Removal Strategy
|
|
Date: 2025-11-24
|
|
"""
|
|
|
|
import httpx
|
|
from typing import Optional, Dict, Any
|
|
from flask import current_app
|
|
|
|
|
|
class TokenVerificationError(Exception):
|
|
"""Token verification failed"""
|
|
pass
|
|
|
|
|
|
def verify_external_token(token: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Verify bearer token with external IndieAuth provider
|
|
|
|
Makes a GET request to the token endpoint with Authorization header.
|
|
The external provider returns token info if valid, or error if invalid.
|
|
|
|
Args:
|
|
token: Bearer token to verify
|
|
|
|
Returns:
|
|
Dict with token info (me, client_id, scope) if valid
|
|
None if token is invalid or verification fails
|
|
|
|
Token info dict contains:
|
|
me: User's profile URL
|
|
client_id: Client application URL
|
|
scope: Space-separated list of scopes
|
|
"""
|
|
token_endpoint = current_app.config.get("TOKEN_ENDPOINT")
|
|
admin_me = current_app.config.get("ADMIN_ME")
|
|
|
|
if not token_endpoint:
|
|
current_app.logger.error(
|
|
"TOKEN_ENDPOINT not configured. Cannot verify external tokens."
|
|
)
|
|
return None
|
|
|
|
if not admin_me:
|
|
current_app.logger.error(
|
|
"ADMIN_ME not configured. Cannot verify token ownership."
|
|
)
|
|
return None
|
|
|
|
try:
|
|
# Verify token with external provider
|
|
headers = {
|
|
"Authorization": f"Bearer {token}",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
current_app.logger.debug(
|
|
f"Verifying token with external provider: {token_endpoint}"
|
|
)
|
|
|
|
response = httpx.get(
|
|
token_endpoint,
|
|
headers=headers,
|
|
timeout=5.0,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
current_app.logger.warning(
|
|
f"Token verification failed: HTTP {response.status_code}"
|
|
)
|
|
return None
|
|
|
|
token_info = response.json()
|
|
|
|
# Validate required fields
|
|
if "me" not in token_info:
|
|
current_app.logger.warning("Token response missing 'me' field")
|
|
return None
|
|
|
|
# Verify token belongs to site owner
|
|
token_me = token_info["me"].rstrip("/")
|
|
expected_me = admin_me.rstrip("/")
|
|
|
|
if token_me != expected_me:
|
|
current_app.logger.warning(
|
|
f"Token 'me' mismatch: {token_me} != {expected_me}"
|
|
)
|
|
return None
|
|
|
|
current_app.logger.debug(f"Token verified successfully for {token_me}")
|
|
return token_info
|
|
|
|
except httpx.TimeoutException:
|
|
current_app.logger.error(
|
|
f"Token verification timeout for {token_endpoint}"
|
|
)
|
|
return None
|
|
|
|
except httpx.RequestError as e:
|
|
current_app.logger.error(
|
|
f"Token verification request failed: {e}"
|
|
)
|
|
return None
|
|
|
|
except Exception as e:
|
|
current_app.logger.error(
|
|
f"Unexpected error during token verification: {e}"
|
|
)
|
|
return None
|
|
|
|
|
|
def check_scope(required_scope: str, token_scope: str) -> bool:
|
|
"""
|
|
Check if token has required scope
|
|
|
|
Scopes are space-separated in token_scope string.
|
|
Any scope in the list satisfies the requirement.
|
|
|
|
Args:
|
|
required_scope: Scope needed (e.g., "create")
|
|
token_scope: Space-separated scope string from token
|
|
|
|
Returns:
|
|
True if token has required scope, False otherwise
|
|
|
|
Examples:
|
|
>>> check_scope("create", "create update")
|
|
True
|
|
>>> check_scope("create", "read")
|
|
False
|
|
>>> check_scope("create", "")
|
|
False
|
|
"""
|
|
if not token_scope:
|
|
return False
|
|
|
|
scopes = token_scope.split()
|
|
return required_scope in scopes
|