feat: Implement PKCE authentication for IndieLogin.com

This fixes critical IndieAuth authentication by implementing PKCE (Proof Key
for Code Exchange) as required by IndieLogin.com API specification.

Added:
- PKCE code_verifier and code_challenge generation (RFC 7636)
- Database column: auth_state.code_verifier for PKCE support
- Issuer validation for authentication callbacks
- Comprehensive PKCE unit tests (6 tests, all passing)
- Database migration script for code_verifier column

Changed:
- Corrected IndieLogin.com API endpoints (/authorize and /token)
- State token validation now returns code_verifier for token exchange
- Authentication flow follows IndieLogin.com API specification exactly
- Enhanced logging with code_verifier redaction

Removed:
- OAuth metadata endpoint (/.well-known/oauth-authorization-server)
  Added in v0.7.0 but not required by IndieLogin.com
- h-app microformats markup from templates
  Modified in v0.7.1 but not used by IndieLogin.com
- indieauth-metadata link from HTML head

Security:
- PKCE prevents authorization code interception attacks
- Issuer validation prevents token substitution attacks
- Code verifier securely stored, redacted in logs, and single-use

Documentation:
- Version: 0.8.0
- CHANGELOG updated with v0.8.0 entry and v0.7.x notes
- ADR-016 and ADR-017 marked as superseded by ADR-019
- Implementation report created in docs/reports/
- Test update guide created in TODO_TEST_UPDATES.md

Breaking Changes:
- Users mid-authentication will need to restart login after upgrade
- Database migration required before deployment

Related: ADR-019

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 15:43:38 -07:00
parent caabf0087e
commit 5e50330bdf
18 changed files with 4208 additions and 125 deletions

View File

@@ -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.7.1"
__version_info__ = (0, 7, 1)
__version__ = "0.8.0"
__version_info__ = (0, 8, 0)

View File

@@ -27,6 +27,7 @@ Exceptions:
IndieLoginError: External service error
"""
import base64
import hashlib
import logging
import secrets
@@ -67,6 +68,42 @@ class IndieLoginError(AuthError):
pass
# PKCE helper functions
def _generate_pkce_verifier() -> str:
"""
Generate PKCE code_verifier.
Creates a cryptographically random 43-character URL-safe string
as required by PKCE specification (RFC 7636).
Returns:
URL-safe base64-encoded random string (43 characters)
"""
# Generate 32 random bytes = 43 chars when base64-url encoded
verifier = secrets.token_urlsafe(32)
return verifier
def _generate_pkce_challenge(verifier: str) -> str:
"""
Generate PKCE code_challenge from code_verifier.
Creates SHA256 hash of verifier and encodes as base64-url string
per RFC 7636 S256 method.
Args:
verifier: The code_verifier string from _generate_pkce_verifier()
Returns:
Base64-URL encoded SHA256 hash (43 characters)
"""
# SHA256 hash the verifier
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
# Base64-URL encode (no padding)
challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
return challenge
# Logging helper functions
def _redact_token(value: str, show_chars: int = 6) -> str:
"""
@@ -108,6 +145,8 @@ def _log_http_request(method: str, url: str, data: dict, headers: dict = None) -
safe_data["code"] = _redact_token(safe_data["code"])
if "state" in safe_data:
safe_data["state"] = _redact_token(safe_data["state"], 8)
if "code_verifier" in safe_data:
safe_data["code_verifier"] = _redact_token(safe_data["code_verifier"])
current_app.logger.debug(
f"IndieAuth HTTP Request:\n"
@@ -191,35 +230,37 @@ def _generate_state_token() -> str:
return secrets.token_urlsafe(32)
def _verify_state_token(state: str) -> bool:
def _verify_state_token(state: str) -> Optional[str]:
"""
Verify and consume CSRF state token
Verify and consume CSRF state token, returning code_verifier.
Args:
state: State token to verify
Returns:
True if valid, False otherwise
code_verifier string if valid, None if invalid or expired
"""
db = get_db(current_app)
# Check if state exists and not expired
# Check if state exists and not expired, retrieve code_verifier
result = db.execute(
"""
SELECT 1 FROM auth_state
SELECT code_verifier FROM auth_state
WHERE state = ? AND expires_at > datetime('now')
""",
""",
(state,),
).fetchone()
if not result:
return False
return None
code_verifier = result['code_verifier']
# Delete state (single-use)
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
db.commit()
return True
return code_verifier
def _cleanup_expired_sessions() -> None:
@@ -248,7 +289,7 @@ def _cleanup_expired_sessions() -> None:
# Core authentication functions
def initiate_login(me_url: str) -> str:
"""
Initiate IndieLogin authentication flow
Initiate IndieLogin authentication flow with PKCE.
Args:
me_url: User's IndieWeb identity URL
@@ -269,54 +310,65 @@ def initiate_login(me_url: str) -> str:
state = _generate_state_token()
current_app.logger.debug(f"Auth: Generated state token: {_redact_token(state, 8)}")
# Store state in database (5-minute expiry)
# Generate PKCE verifier and challenge
code_verifier = _generate_pkce_verifier()
code_challenge = _generate_pkce_challenge(code_verifier)
current_app.logger.debug(
f"Auth: Generated PKCE pair:\n"
f" verifier: {_redact_token(code_verifier)}\n"
f" challenge: {_redact_token(code_challenge)}"
)
# 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"
db.execute(
"""
INSERT INTO auth_state (state, expires_at, redirect_uri)
VALUES (?, ?, ?)
""",
(state, expires_at, redirect_uri),
INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri)
VALUES (?, ?, ?, ?)
""",
(state, code_verifier, expires_at, redirect_uri),
)
db.commit()
# Build IndieLogin URL
# Build IndieLogin authorization URL with PKCE
params = {
"me": me_url,
"client_id": current_app.config["SITE_URL"],
"redirect_uri": redirect_uri,
"state": state,
"response_type": "code",
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
current_app.logger.debug(
f"Auth: Building authorization URL with params: {{\n"
f" 'me': '{me_url}',\n"
f" 'client_id': '{current_app.config['SITE_URL']}',\n"
f" 'redirect_uri': '{redirect_uri}',\n"
f" 'state': '{_redact_token(state, 8)}',\n"
f" 'response_type': 'code'\n"
f"}}"
f"Auth: Building authorization URL with params:\n"
f" me: {me_url}\n"
f" client_id: {current_app.config['SITE_URL']}\n"
f" redirect_uri: {redirect_uri}\n"
f" state: {_redact_token(state, 8)}\n"
f" code_challenge: {_redact_token(code_challenge)}\n"
f" code_challenge_method: S256"
)
auth_url = f"{current_app.config['INDIELOGIN_URL']}/auth?{urlencode(params)}"
# CORRECT ENDPOINT: /authorize (not /auth)
auth_url = f"{current_app.config['INDIELOGIN_URL']}/authorize?{urlencode(params)}"
# Log authentication attempt
current_app.logger.info(f"Auth: Authentication initiated for {me_url}")
return auth_url
def handle_callback(code: str, state: str) -> Optional[str]:
def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optional[str]:
"""
Handle IndieLogin callback
Handle IndieLogin callback with PKCE verification.
Args:
code: Authorization code from IndieLogin
state: CSRF state token
iss: Issuer identifier (should be https://indielogin.com/)
Returns:
Session token if successful, None otherwise
@@ -328,31 +380,45 @@ def handle_callback(code: str, state: str) -> Optional[str]:
"""
current_app.logger.debug(f"Auth: Verifying state token: {_redact_token(state, 8)}")
# Verify state token (CSRF protection)
if not _verify_state_token(state):
current_app.logger.warning("Auth: Invalid state token received (possible CSRF or expired token)")
# Verify state token and retrieve code_verifier (CSRF protection)
code_verifier = _verify_state_token(state)
if not code_verifier:
current_app.logger.warning(
"Auth: Invalid state token received (possible CSRF or expired token)"
)
raise InvalidStateError("Invalid or expired state token")
current_app.logger.debug("Auth: State token valid and consumed")
current_app.logger.debug("Auth: State token valid, code_verifier retrieved")
# Prepare token exchange request
# 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}")
current_app.logger.debug(f"Auth: Issuer verified: {iss}")
# Prepare token exchange request with PKCE verifier
token_exchange_data = {
"code": code,
"client_id": current_app.config["SITE_URL"],
"redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback",
"code_verifier": code_verifier, # PKCE verification
}
# Log the request
# Log the request (code_verifier will be redacted)
_log_http_request(
method="POST",
url=f"{current_app.config['INDIELOGIN_URL']}/auth",
url=f"{current_app.config['INDIELOGIN_URL']}/token",
data=token_exchange_data,
)
# Exchange code for identity
# Exchange code for identity (CORRECT ENDPOINT: /token)
try:
response = httpx.post(
f"{current_app.config['INDIELOGIN_URL']}/auth",
f"{current_app.config['INDIELOGIN_URL']}/token",
data=token_exchange_data,
timeout=10.0,
)
@@ -369,11 +435,20 @@ def handle_callback(code: str, state: str) -> Optional[str]:
current_app.logger.error(f"Auth: IndieLogin 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}")
raise IndieLoginError(f"IndieLogin returned error: {e.response.status_code}")
current_app.logger.error(
f"Auth: IndieLogin returned error: {e.response.status_code} - {e.response.text}"
)
raise IndieLoginError(
f"IndieLogin returned error: {e.response.status_code}"
)
# Parse response
data = response.json()
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")
me = data.get("me")
if not me:

View File

@@ -57,6 +57,7 @@ CREATE INDEX IF NOT EXISTS idx_tokens_me ON tokens(me);
-- CSRF state tokens (for IndieAuth flow)
CREATE TABLE IF NOT EXISTS auth_state (
state TEXT PRIMARY KEY,
code_verifier TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
redirect_uri TEXT

View File

@@ -89,11 +89,13 @@ def callback():
Handle IndieLogin callback
Processes the OAuth callback from IndieLogin.com, validates the
authorization code and state token, and creates an authenticated session.
authorization code, state token, and issuer, then creates an
authenticated session using PKCE verification.
Query parameters:
code: Authorization code from IndieLogin
state: CSRF state token
iss: Issuer identifier (should be https://indielogin.com/)
Returns:
Redirect to admin dashboard on success, login form on failure
@@ -103,14 +105,15 @@ def callback():
"""
code = request.args.get("code")
state = request.args.get("state")
iss = request.args.get("iss") # Extract issuer parameter
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
try:
# Handle callback and create session
session_token = handle_callback(code, state)
# Handle callback and create session with PKCE verification
session_token = handle_callback(code, state, iss) # Pass issuer
# Create response with redirect
response = redirect(url_for("admin.dashboard"))

View File

@@ -8,7 +8,7 @@ No authentication required for these routes.
import hashlib
from datetime import datetime, timedelta
from flask import Blueprint, abort, render_template, Response, current_app, jsonify
from flask import Blueprint, abort, render_template, Response, current_app
from starpunk.notes import list_notes, get_note
from starpunk.feed import generate_feed
@@ -145,73 +145,3 @@ def feed():
response.headers["ETag"] = etag
return response
@bp.route("/.well-known/oauth-authorization-server")
def oauth_client_metadata():
"""
OAuth Client ID Metadata Document endpoint.
Returns JSON metadata about this IndieAuth client for authorization
server discovery. Required by IndieAuth specification section 4.2.
This endpoint implements the modern IndieAuth (2022+) client discovery
mechanism using OAuth Client ID Metadata Documents. Authorization servers
like IndieLogin.com fetch this metadata to verify client registration
and obtain redirect URIs.
Returns:
JSON response with client metadata
Response Format:
{
"issuer": "https://example.com",
"client_id": "https://example.com",
"client_name": "Site Name",
"client_uri": "https://example.com",
"redirect_uris": ["https://example.com/auth/callback"],
"grant_types_supported": ["authorization_code"],
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"]
}
Headers:
Content-Type: application/json
Cache-Control: public, max-age=86400 (24 hours)
References:
- IndieAuth Spec: https://indieauth.spec.indieweb.org/#client-information-discovery
- OAuth Client Metadata: https://www.ietf.org/archive/id/draft-parecki-oauth-client-id-metadata-document-00.html
- ADR-017: OAuth Client ID Metadata Document Implementation
Examples:
>>> response = client.get('/.well-known/oauth-authorization-server')
>>> response.status_code
200
>>> data = response.get_json()
>>> data['client_id']
'https://example.com'
"""
# Build metadata document using configuration values
# client_id MUST exactly match the URL where this document is served
metadata = {
"issuer": current_app.config["SITE_URL"],
"client_id": current_app.config["SITE_URL"],
"client_name": current_app.config.get("SITE_NAME", "StarPunk"),
"client_uri": current_app.config["SITE_URL"],
"redirect_uris": [f"{current_app.config['SITE_URL']}/auth/callback"],
"grant_types_supported": ["authorization_code"],
"response_types_supported": ["code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
}
# Create JSON response
response = jsonify(metadata)
# Cache for 24 hours (metadata rarely changes)
response.cache_control.max_age = 86400
response.cache_control.public = True
return response