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:
153
starpunk/auth.py
153
starpunk/auth.py
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user