fix(auth): Implement IndieAuth endpoint discovery per W3C spec
BREAKING: Removes INDIELOGIN_URL config - endpoints are now properly discovered from user's profile URL as required by W3C IndieAuth spec. - auth.py: Uses discover_endpoints() to find authorization_endpoint - config.py: Deprecation warning for obsolete INDIELOGIN_URL setting - auth_external.py: Relaxed validation (allows auth-only flows) - tests: Updated to mock endpoint discovery This fixes a regression where admin login was hardcoded to use indielogin.com instead of respecting the user's declared endpoints. Version: 1.5.0-hotfix.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
129
starpunk/auth.py
129
starpunk/auth.py
@@ -38,6 +38,7 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
from flask import current_app, g, redirect, request, session, url_for
|
||||
|
||||
from starpunk.auth_external import discover_endpoints, DiscoveryError, normalize_url
|
||||
from starpunk.database import get_db
|
||||
from starpunk.utils import is_valid_url
|
||||
|
||||
@@ -250,16 +251,20 @@ def _cleanup_expired_sessions() -> None:
|
||||
# Core authentication functions
|
||||
def initiate_login(me_url: str) -> str:
|
||||
"""
|
||||
Initiate IndieLogin authentication flow.
|
||||
Initiate IndieAuth authentication flow with endpoint discovery.
|
||||
|
||||
Per W3C IndieAuth spec, discovers authorization_endpoint from user's
|
||||
profile URL rather than using a hardcoded service.
|
||||
|
||||
Args:
|
||||
me_url: User's IndieWeb identity URL
|
||||
|
||||
Returns:
|
||||
Redirect URL to IndieLogin.com
|
||||
Redirect URL to discovered authorization endpoint
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid me_url format
|
||||
DiscoveryError: Failed to discover endpoints from profile
|
||||
"""
|
||||
# Validate URL format
|
||||
if not is_valid_url(me_url):
|
||||
@@ -267,6 +272,23 @@ def initiate_login(me_url: str) -> str:
|
||||
|
||||
current_app.logger.debug(f"Auth: Validating me URL: {me_url}")
|
||||
|
||||
# Discover authorization endpoint from user's profile URL
|
||||
# Per IndieAuth spec: clients MUST discover endpoints, not hardcode them
|
||||
try:
|
||||
endpoints = discover_endpoints(me_url)
|
||||
except DiscoveryError as e:
|
||||
current_app.logger.error(f"Auth: Endpoint discovery failed for {me_url}: {e}")
|
||||
raise
|
||||
|
||||
auth_endpoint = endpoints.get('authorization_endpoint')
|
||||
if not auth_endpoint:
|
||||
raise DiscoveryError(
|
||||
f"No authorization_endpoint found at {me_url}. "
|
||||
"Ensure your profile has IndieAuth link elements or headers."
|
||||
)
|
||||
|
||||
current_app.logger.info(f"Auth: Discovered authorization_endpoint: {auth_endpoint}")
|
||||
|
||||
# Generate CSRF state token
|
||||
state = _generate_state_token()
|
||||
current_app.logger.debug(f"Auth: Generated state token: {_redact_token(state, 8)}")
|
||||
@@ -285,7 +307,7 @@ def initiate_login(me_url: str) -> str:
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Build IndieLogin authorization URL
|
||||
# Build authorization URL
|
||||
params = {
|
||||
"me": me_url,
|
||||
"client_id": current_app.config["SITE_URL"],
|
||||
@@ -303,16 +325,9 @@ def initiate_login(me_url: str) -> str:
|
||||
f" response_type: code"
|
||||
)
|
||||
|
||||
# CORRECT ENDPOINT: /authorize (not /auth)
|
||||
auth_url = f"{current_app.config['INDIELOGIN_URL']}/authorize?{urlencode(params)}"
|
||||
|
||||
# Log the complete authorization URL for debugging
|
||||
current_app.logger.debug(
|
||||
"Auth: Complete authorization URL (GET request):\n"
|
||||
" %s",
|
||||
auth_url
|
||||
)
|
||||
auth_url = f"{auth_endpoint}?{urlencode(params)}"
|
||||
|
||||
current_app.logger.debug(f"Auth: Complete authorization URL: {auth_url}")
|
||||
current_app.logger.info(f"Auth: Authentication initiated for {me_url}")
|
||||
|
||||
return auth_url
|
||||
@@ -320,12 +335,18 @@ def initiate_login(me_url: str) -> str:
|
||||
|
||||
def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Handle IndieLogin callback.
|
||||
Handle IndieAuth callback with endpoint discovery.
|
||||
|
||||
Discovers authorization_endpoint from ADMIN_ME profile and exchanges
|
||||
authorization code for identity verification.
|
||||
|
||||
Per IndieAuth spec: Authentication-only flows POST to the authorization
|
||||
endpoint (not token endpoint) and do not include grant_type.
|
||||
|
||||
Args:
|
||||
code: Authorization code from IndieLogin
|
||||
code: Authorization code from authorization server
|
||||
state: CSRF state token
|
||||
iss: Issuer identifier (should be https://indielogin.com/)
|
||||
iss: Issuer identifier (optional, for security validation)
|
||||
|
||||
Returns:
|
||||
Session token if successful, None otherwise
|
||||
@@ -346,44 +367,54 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
|
||||
current_app.logger.debug("Auth: State token valid")
|
||||
|
||||
# Verify issuer (security check)
|
||||
expected_iss = f"{current_app.config['INDIELOGIN_URL']}/"
|
||||
if iss and iss != expected_iss:
|
||||
current_app.logger.warning(
|
||||
f"Auth: Invalid issuer received: {iss} (expected {expected_iss})"
|
||||
)
|
||||
raise IndieLoginError(f"Invalid issuer: {iss}")
|
||||
# Discover authorization endpoint from ADMIN_ME profile
|
||||
admin_me = current_app.config.get("ADMIN_ME")
|
||||
if not admin_me:
|
||||
current_app.logger.error("Auth: ADMIN_ME not configured")
|
||||
raise IndieLoginError("ADMIN_ME not configured")
|
||||
|
||||
current_app.logger.debug(f"Auth: Issuer verified: {iss}")
|
||||
try:
|
||||
endpoints = discover_endpoints(admin_me)
|
||||
except DiscoveryError as e:
|
||||
current_app.logger.error(f"Auth: Endpoint discovery failed: {e}")
|
||||
raise IndieLoginError(f"Failed to discover endpoints: {e}")
|
||||
|
||||
# Use authorization_endpoint for authentication-only flow (identity verification)
|
||||
# Per IndieAuth spec: auth-only flows POST to authorization_endpoint, not token_endpoint
|
||||
auth_endpoint = endpoints.get('authorization_endpoint')
|
||||
if not auth_endpoint:
|
||||
raise IndieLoginError(
|
||||
f"No authorization_endpoint found at {admin_me}. "
|
||||
"Ensure your profile has IndieAuth endpoints configured."
|
||||
)
|
||||
|
||||
current_app.logger.debug(f"Auth: Using authorization_endpoint: {auth_endpoint}")
|
||||
|
||||
# Verify issuer if provided (security check - optional)
|
||||
if iss:
|
||||
current_app.logger.debug(f"Auth: Issuer provided: {iss}")
|
||||
|
||||
# Prepare code verification request
|
||||
# Note: For authentication-only flows (identity verification), we use the
|
||||
# authorization endpoint, not the token endpoint. grant_type is not needed.
|
||||
# See IndieAuth spec: authorization endpoint for authentication,
|
||||
# token endpoint for access tokens.
|
||||
# Note: grant_type is NOT included for authentication-only flows per IndieAuth spec
|
||||
token_exchange_data = {
|
||||
"code": code,
|
||||
"client_id": current_app.config["SITE_URL"],
|
||||
"redirect_uri": f"{current_app.config['SITE_URL']}auth/callback",
|
||||
}
|
||||
|
||||
# Use authorization endpoint for authentication-only flow (identity verification)
|
||||
token_url = f"{current_app.config['INDIELOGIN_URL']}/authorize"
|
||||
|
||||
# Log the request
|
||||
_log_http_request(
|
||||
method="POST",
|
||||
url=token_url,
|
||||
url=auth_endpoint,
|
||||
data=token_exchange_data,
|
||||
)
|
||||
|
||||
# Log detailed httpx request info for debugging
|
||||
current_app.logger.debug(
|
||||
"Auth: Sending code verification request to authorization endpoint:\n"
|
||||
" Method: POST\n"
|
||||
" URL: %s\n"
|
||||
" Data: code=%s, client_id=%s, redirect_uri=%s",
|
||||
token_url,
|
||||
auth_endpoint,
|
||||
_redact_token(code),
|
||||
token_exchange_data["client_id"],
|
||||
token_exchange_data["redirect_uri"],
|
||||
@@ -392,23 +423,22 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
# Exchange code for identity at authorization endpoint (authentication-only flow)
|
||||
try:
|
||||
response = httpx.post(
|
||||
token_url,
|
||||
auth_endpoint,
|
||||
data=token_exchange_data,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
# Log detailed httpx response info for debugging
|
||||
current_app.logger.debug(
|
||||
"Auth: Received code verification response:\n"
|
||||
" Status: %d\n"
|
||||
" Headers: %s\n"
|
||||
" Body: %s",
|
||||
response.status_code,
|
||||
{k: v for k, v in dict(response.headers).items() if k.lower() not in ["set-cookie", "authorization"]},
|
||||
{k: v for k, v in dict(response.headers).items()
|
||||
if k.lower() not in ["set-cookie", "authorization"]},
|
||||
_redact_token(response.text) if response.text else "(empty)",
|
||||
)
|
||||
|
||||
# Log the response (legacy helper)
|
||||
_log_http_response(
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
@@ -417,40 +447,37 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
|
||||
response.raise_for_status()
|
||||
except httpx.RequestError as e:
|
||||
current_app.logger.error(f"Auth: IndieLogin request failed: {e}")
|
||||
current_app.logger.error(f"Auth: Authorization endpoint request failed: {e}")
|
||||
raise IndieLoginError(f"Failed to verify code: {e}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
current_app.logger.error(
|
||||
f"Auth: IndieLogin returned error: {e.response.status_code} - {e.response.text}"
|
||||
f"Auth: Authorization endpoint returned error: {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
raise IndieLoginError(
|
||||
f"IndieLogin returned error: {e.response.status_code}"
|
||||
f"Authorization endpoint returned error: {e.response.status_code}"
|
||||
)
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Auth: Failed to parse IndieLogin response: {e}")
|
||||
raise IndieLoginError("Invalid JSON response from IndieLogin")
|
||||
current_app.logger.error(f"Auth: Failed to parse authorization endpoint response: {e}")
|
||||
raise IndieLoginError("Invalid JSON response from authorization endpoint")
|
||||
|
||||
me = data.get("me")
|
||||
|
||||
if not me:
|
||||
current_app.logger.error("Auth: No identity returned from IndieLogin")
|
||||
raise IndieLoginError("No identity returned from IndieLogin")
|
||||
current_app.logger.error("Auth: No identity returned from authorization endpoint")
|
||||
raise IndieLoginError("No identity returned from authorization endpoint")
|
||||
|
||||
current_app.logger.debug(f"Auth: Received identity from IndieLogin: {me}")
|
||||
current_app.logger.debug(f"Auth: Received identity: {me}")
|
||||
|
||||
# Verify this is the admin user
|
||||
admin_me = current_app.config.get("ADMIN_ME")
|
||||
if not admin_me:
|
||||
current_app.logger.error("Auth: ADMIN_ME not configured")
|
||||
raise UnauthorizedError("Admin user not configured")
|
||||
|
||||
current_app.logger.info(f"Auth: Verifying admin authorization for me={me}")
|
||||
|
||||
if me != admin_me:
|
||||
# Normalize URLs for comparison (handles trailing slashes and case differences)
|
||||
# This is correct per IndieAuth spec - the returned 'me' is the canonical form
|
||||
if normalize_url(me) != normalize_url(admin_me):
|
||||
current_app.logger.warning(
|
||||
f"Auth: Unauthorized login attempt: {me} (expected {admin_me})"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user