""" 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