diff --git a/CHANGELOG.md b/CHANGELOG.md index 444292b..dd3f6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.1] - 2025-11-19 + +### Fixed +- **IndieAuth client_id trailing slash**: Added automatic trailing slash normalization to SITE_URL + - IndieLogin.com spec requires client_id URLs to have trailing slash for root domains + - Fixes "client_id is not registered" authentication errors + - Normalizes https://example.com to https://example.com/ +- **Enhanced debug logging**: Added detailed httpx request/response logging for token exchange + - Shows exact HTTP method, URL, headers, and body being sent to IndieLogin.com + - Helps troubleshoot authentication issues with full visibility + - All sensitive data (tokens, verifiers) automatically redacted + +### Changed +- SITE_URL configuration now automatically adds trailing slash if missing + ## [0.9.0] - 2025-11-19 ### Added diff --git a/starpunk/__init__.py b/starpunk/__init__.py index 31388c3..c07e6e3 100644 --- a/starpunk/__init__.py +++ b/starpunk/__init__.py @@ -153,5 +153,5 @@ def create_app(config=None): # Package version (Semantic Versioning 2.0.0) # See docs/standards/versioning-strategy.md for details -__version__ = "0.9.0" -__version_info__ = (0, 9, 0) +__version__ = "0.9.1" +__version_info__ = (0, 9, 1) diff --git a/starpunk/auth.py b/starpunk/auth.py index ea02bbf..78f91c0 100644 --- a/starpunk/auth.py +++ b/starpunk/auth.py @@ -322,7 +322,7 @@ def initiate_login(me_url: str) -> str: # Store state and verifier in database (5-minute expiry) db = get_db(current_app) expires_at = datetime.utcnow() + timedelta(minutes=5) - redirect_uri = f"{current_app.config['SITE_URL']}/auth/callback" + redirect_uri = f"{current_app.config['SITE_URL']}auth/callback" db.execute( """ @@ -404,26 +404,52 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona token_exchange_data = { "code": code, "client_id": current_app.config["SITE_URL"], - "redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback", + "redirect_uri": f"{current_app.config['SITE_URL']}auth/callback", "code_verifier": code_verifier, # PKCE verification } + token_url = f"{current_app.config['INDIELOGIN_URL']}/token" + # Log the request (code_verifier will be redacted) _log_http_request( method="POST", - url=f"{current_app.config['INDIELOGIN_URL']}/token", + url=token_url, data=token_exchange_data, ) + # Log detailed httpx request info for debugging + current_app.logger.debug( + "Auth: Sending token exchange request:\n" + " Method: POST\n" + " URL: %s\n" + " Data: code=%s, client_id=%s, redirect_uri=%s, code_verifier=%s", + token_url, + _redact_token(code), + token_exchange_data["client_id"], + token_exchange_data["redirect_uri"], + _redact_token(code_verifier), + ) + # Exchange code for identity (CORRECT ENDPOINT: /token) try: response = httpx.post( - f"{current_app.config['INDIELOGIN_URL']}/token", + token_url, data=token_exchange_data, timeout=10.0, ) - # Log the response + # Log detailed httpx response info for debugging + current_app.logger.debug( + "Auth: Received token exchange 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"]}, + _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), diff --git a/starpunk/config.py b/starpunk/config.py index cd16a2e..a8293df 100644 --- a/starpunk/config.py +++ b/starpunk/config.py @@ -20,7 +20,10 @@ def load_config(app, config_override=None): load_dotenv() # Site configuration - app.config["SITE_URL"] = os.getenv("SITE_URL", "http://localhost:5000") + # IndieWeb/OAuth specs require trailing slash for root URLs used as client_id + # See: https://indielogin.com/ OAuth client requirements + site_url = os.getenv("SITE_URL", "http://localhost:5000") + app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/' app.config["SITE_NAME"] = os.getenv("SITE_NAME", "StarPunk") app.config["SITE_AUTHOR"] = os.getenv("SITE_AUTHOR", "Unknown") app.config["SITE_DESCRIPTION"] = os.getenv( @@ -73,6 +76,11 @@ def load_config(app, config_override=None): if config_override: app.config.update(config_override) + # Normalize SITE_URL trailing slash (in case override provided URL without slash) + if "SITE_URL" in app.config: + site_url = app.config["SITE_URL"] + app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/' + # Convert path strings to Path objects (in case overrides provided strings) if isinstance(app.config["DATA_PATH"], str): app.config["DATA_PATH"] = Path(app.config["DATA_PATH"])