feat: Complete IndieAuth server removal (Phases 2-4)
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>
This commit is contained in:
153
starpunk/auth_external.py
Normal file
153
starpunk/auth_external.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user