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>
39 KiB
IndieAuth PKCE Authentication - Technical Design
Status: Ready for Implementation Related ADR: ADR-019 Last Updated: 2025-11-19
Overview
This document provides complete technical specifications for implementing IndieAuth authentication using IndieLogin.com's API with PKCE (Proof Key for Code Exchange). This design corrects the broken authentication implementation by following the official IndieLogin.com API requirements exactly.
Table of Contents
- Authentication Flow
- PKCE Implementation
- Database Schema Changes
- Code Changes
- Code Removal
- Testing Strategy
- Error Handling
- Security Considerations
- Implementation Guide
Authentication Flow
Complete Flow Diagram
┌─────────────┐
│ User clicks │
│ "Login" │
└──────┬──────┘
│
▼
┌─────────────────────────────────────┐
│ StarPunk: initiate_login() │
│ 1. Validate me_url │
│ 2. Generate state token (CSRF) │
│ 3. Generate code_verifier (random) │
│ 4. Generate code_challenge (SHA256) │
│ 5. Store state + verifier in DB │
│ 6. Redirect to IndieLogin.com │
└──────┬──────────────────────────────┘
│
│ GET /authorize with:
│ - me, client_id, redirect_uri
│ - state, code_challenge, code_challenge_method
│
▼
┌─────────────────────────────────────┐
│ IndieLogin.com │
│ 1. User enters their website URL │
│ 2. Scans for rel="me" links │
│ 3. Shows auth providers (GitHub etc)│
│ 4. User authenticates via provider │
│ 5. Provider verifies identity │
│ 6. Stores code_challenge │
└──────┬──────────────────────────────┘
│
│ Redirect to redirect_uri with:
│ - code, state, iss
│
▼
┌─────────────────────────────────────┐
│ StarPunk: handle_callback() │
│ 1. Validate state matches DB │
│ 2. Validate iss = indielogin.com │
│ 3. Retrieve code_verifier from DB │
│ 4. POST to /token with code+verifier│
│ 5. Receive {"me": "user-url"} │
│ 6. Verify me == ADMIN_ME │
│ 7. Create session in database │
│ 8. Set session cookie (HttpOnly) │
│ 9. Redirect to /admin │
└──────┬──────────────────────────────┘
│
▼
┌─────────────┐
│ Logged In │
│ (Admin) │
└─────────────┘
Step 1: Authorization Request
Endpoint: https://indielogin.com/authorize
Method: GET (via redirect)
Required Parameters:
me User's IndieWeb identity URL (e.g., https://user-site.com)
client_id Application URL (e.g., https://starpunk.example.com)
redirect_uri Callback URL (e.g., https://starpunk.example.com/auth/callback)
state Random CSRF token (43+ characters, URL-safe)
code_challenge Base64-URL encoded SHA256 hash of code_verifier
code_challenge_method Must be "S256"
Optional Parameters:
prompt=login Force fresh authentication (don't use existing session)
Example URL:
https://indielogin.com/authorize?
me=https://user-site.com&
client_id=https://starpunk.example.com&
redirect_uri=https://starpunk.example.com/auth/callback&
state=abc123xyz789random43characters&
code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
code_challenge_method=S256
Step 2: User Authentication (IndieLogin.com)
No implementation required - fully handled by IndieLogin.com:
- User enters their website URL (or uses pre-filled from
meparameter) - IndieLogin.com scans user's website for
rel="me"links - Shows available authentication providers (GitHub, Twitter, GitLab, Codeberg, email)
- User authenticates via chosen provider
- Provider verifies user owns the identity URL
- IndieLogin.com generates authorization code
- Stores
code_challengeassociated with authorization code
Step 3: Authorization Callback
Redirect back to our application with these parameters:
code Authorization code (JWT format, single-use, short-lived)
state Our original state value (must match stored value)
iss Issuer identifier (should be "https://indielogin.com/")
Example Callback URL:
https://starpunk.example.com/auth/callback?
code=eyJ0eXAiOiJKV1QiLCJhbGc...&
state=abc123xyz789random43characters&
iss=https://indielogin.com/
Our Validation:
- Check
stateexists and matches database record - Check
statenot expired (5 minute max age) - Check
issequalshttps://indielogin.com/ - Retrieve
code_verifierassociated withstate - Delete
statefrom database (single-use)
Step 4: Token Exchange
Endpoint: https://indielogin.com/token
Method: POST
Content-Type: application/x-www-form-urlencoded
Required Parameters:
code Authorization code from callback
client_id Application URL (same as authorization request)
redirect_uri Callback URL (same as authorization request)
code_verifier Original PKCE verifier (before hashing)
Example Request:
POST /token HTTP/1.1
Host: indielogin.com
Content-Type: application/x-www-form-urlencoded
code=eyJ0eXAiOiJKV1QiLCJhbGc...&
client_id=https://starpunk.example.com&
redirect_uri=https://starpunk.example.com/auth/callback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Success Response (200 OK):
{
"me": "https://user-site.com/"
}
Error Response (400 Bad Request):
{
"error": "invalid_request",
"error_description": "The code provided was not valid"
}
Other Error Codes:
invalid_grant- Code expired or already usedinvalid_client- client_id mismatchinvalid_request- Missing required parameterunauthorized_client- code_verifier doesn't match code_challenge
PKCE Implementation
Code Verifier Generation
Requirements (RFC 7636):
- Random URL-safe string
- Length: 43-128 characters
- Characters:
[A-Z],[a-z],[0-9],-,.,_,~
Implementation:
import secrets
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)
"""
# secrets.token_urlsafe(32) generates 32 random bytes
# Base64-URL encoding produces 43 characters
verifier = secrets.token_urlsafe(32)
return verifier
Example Output:
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Code Challenge Generation
Requirements (RFC 7636, S256 method):
- SHA256 hash the code_verifier
- Base64-URL encode the hash
- Remove padding (
=)
Implementation:
import hashlib
import base64
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 (returns 32 bytes)
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
# Base64-URL encode (produces 44 characters with padding)
challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
# Remove padding (produces 43 characters)
return challenge.rstrip('=')
Example:
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = _generate_pkce_challenge(verifier)
# Result: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
PKCE Storage and Lifecycle
Storage: Database table auth_state with code_verifier column
Lifecycle:
- Generate: Create verifier during
initiate_login() - Store: Save with state token (5 minute TTL)
- Retrieve: Fetch using state during
handle_callback() - Send: Include in token exchange POST
- Delete: Remove from database after token exchange (single-use)
Security Properties:
- Verifier never sent in URL (only in POST body)
- Challenge sent in URL (safe - can't reverse SHA256)
- Verifier-challenge pair single-use
- Short-lived (5 minutes max)
Database Schema Changes
Current Schema
CREATE TABLE auth_state (
state TEXT PRIMARY KEY,
expires_at TIMESTAMP NOT NULL,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Required Change
Add code_verifier column:
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
Updated Schema (For Reference)
CREATE TABLE auth_state (
state TEXT PRIMARY KEY,
code_verifier TEXT NOT NULL, -- NEW COLUMN
expires_at TIMESTAMP NOT NULL,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Column Details:
code_verifier: Stores PKCE verifier (43-character string)NOT NULL: Must always be presentDEFAULT '': Allows migration of existing rows
Index: Primary key on state is sufficient (state lookup is only access pattern)
Code Changes
File: /home/phil/Projects/starpunk/starpunk/auth.py
Change 1: Add Import
Location: Top of file, with other imports
Add:
import base64 # For PKCE challenge encoding
Change 2: Add PKCE Helper Functions
Location: After imports, before existing helper functions (around line 43)
Add:
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
Change 3: Update _verify_state_token() Function
Location: Line ~194
Current Function:
def _verify_state_token(state: str) -> bool:
"""Verify and consume CSRF state token"""
db = get_db(current_app)
result = db.execute(
"""
SELECT 1 FROM auth_state
WHERE state = ? AND expires_at > datetime('now')
""",
(state,),
).fetchone()
if not result:
return False
# Delete state (single-use)
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
db.commit()
return True
Replace With:
def _verify_state_token(state: str) -> Optional[str]:
"""
Verify and consume CSRF state token, returning code_verifier.
Args:
state: State token to verify
Returns:
code_verifier string if valid, None if invalid or expired
"""
db = get_db(current_app)
# Check if state exists and not expired, retrieve code_verifier
result = db.execute(
"""
SELECT code_verifier FROM auth_state
WHERE state = ? AND expires_at > datetime('now')
""",
(state,),
).fetchone()
if not result:
return None
code_verifier = result['code_verifier']
# Delete state (single-use)
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
db.commit()
return code_verifier
Key Changes:
- Return type:
bool→Optional[str] - SELECT
code_verifiercolumn - Return
code_verifierorNone
Change 4: Update initiate_login() Function
Location: Line ~249
Replace Lines 268-310 with:
def initiate_login(me_url: str) -> str:
"""
Initiate IndieLogin authentication flow with PKCE.
Args:
me_url: User's IndieWeb identity URL
Returns:
Redirect URL to IndieLogin.com
Raises:
ValueError: Invalid me_url format
"""
# Validate URL format
if not is_valid_url(me_url):
raise ValueError(f"Invalid URL format: {me_url}")
current_app.logger.debug(f"Auth: Validating me URL: {me_url}")
# Generate CSRF state token
state = _generate_state_token()
current_app.logger.debug(f"Auth: Generated state token: {_redact_token(state, 8)}")
# 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, code_verifier, expires_at, redirect_uri)
VALUES (?, ?, ?, ?)
""",
(state, code_verifier, expires_at, redirect_uri),
)
db.commit()
# Build IndieLogin authorization URL with PKCE
params = {
"me": me_url,
"client_id": current_app.config["SITE_URL"],
"redirect_uri": redirect_uri,
"state": state,
"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" code_challenge: {_redact_token(code_challenge)}\n"
f" code_challenge_method: S256"
)
# CORRECT ENDPOINT: /authorize (not /auth)
auth_url = f"{current_app.config['INDIELOGIN_URL']}/authorize?{urlencode(params)}"
current_app.logger.info(f"Auth: Authentication initiated for {me_url}")
return auth_url
Key Changes:
- Generate
code_verifierandcode_challenge - Store
code_verifierin database with state - Add
code_challengeandcode_challenge_methodto params - Remove
response_typeparameter (not needed) - Change endpoint from
/authto/authorize - Enhanced logging for PKCE parameters
Change 5: Update handle_callback() Function
Location: Line ~313
Replace Lines 313-404 with:
def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optional[str]:
"""
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
Raises:
InvalidStateError: State token validation failed
UnauthorizedError: User not authorized as admin
IndieLoginError: Code exchange failed
"""
current_app.logger.debug(f"Auth: Verifying state token: {_redact_token(state, 8)}")
# 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, code_verifier retrieved")
# 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 (code_verifier will be redacted)
_log_http_request(
method="POST",
url=f"{current_app.config['INDIELOGIN_URL']}/token",
data=token_exchange_data,
)
# Exchange code for identity (CORRECT ENDPOINT: /token)
try:
response = httpx.post(
f"{current_app.config['INDIELOGIN_URL']}/token",
data=token_exchange_data,
timeout=10.0,
)
# Log the response
_log_http_response(
status_code=response.status_code,
headers=dict(response.headers),
body=response.text,
)
response.raise_for_status()
except httpx.RequestError as e:
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} - {e.response.text}"
)
raise IndieLoginError(
f"IndieLogin 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")
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.debug(f"Auth: Received identity from IndieLogin: {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:
current_app.logger.warning(
f"Auth: Unauthorized login attempt: {me} (expected {admin_me})"
)
raise UnauthorizedError(f"User {me} is not authorized")
current_app.logger.debug("Auth: Admin verification passed")
# Create session
session_token = create_session(me)
return session_token
Key Changes:
- Add
issparameter to function signature _verify_state_token()returnscode_verifier(not boolean)- Validate
issparameter matches expected value - Include
code_verifierin token exchange data - Change endpoint from
/authto/token - Better error handling and JSON parsing
- Enhanced logging
Change 6: Update _log_http_request() Function
Location: Line ~90
In the redaction section (around lines 105-110), add:
# Redact sensitive data
safe_data = data.copy()
if "code" in safe_data:
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: # NEW: Redact PKCE verifier
safe_data["code_verifier"] = _redact_token(safe_data["code_verifier"])
File: /home/phil/Projects/starpunk/starpunk/routes/auth.py
Change 7: Update callback() Route
Location: Line ~86, function callback()
In the function (around line 104), update parameter extraction:
Current:
code = request.args.get("code")
state = request.args.get("state")
Change To:
code = request.args.get("code")
state = request.args.get("state")
iss = request.args.get("iss") # NEW: Extract issuer parameter
Then update the callback call (around line 113):
Current:
session_token = handle_callback(code, state)
Change To:
session_token = handle_callback(code, state, iss) # Pass issuer
Update docstring to document the iss parameter:
"""
Handle IndieLogin callback.
Processes the OAuth callback from IndieLogin.com, validates the
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
Sets:
session cookie (HttpOnly, Secure, SameSite=Lax, 30 day expiry)
"""
Code Removal
File: /home/phil/Projects/starpunk/starpunk/routes/public.py
Removal 1: OAuth Metadata Endpoint
DELETE: Lines 150-217 (entire oauth_client_metadata() function)
Function to Remove:
@public_bp.route("/.well-known/oauth-authorization-server")
def oauth_client_metadata():
"""
OAuth Client ID Metadata Document endpoint.
[... entire function ...]
"""
Reason: Not required by IndieLogin.com API
File: /home/phil/Projects/starpunk/templates/base.html
Removal 2: IndieAuth Metadata Link
DELETE: Line ~11
<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">
Reason: Links to removed OAuth metadata endpoint
Removal 3: h-app Microformats
DELETE: Lines ~48-51
<!-- IndieAuth client discovery (h-app microformats) -->
<div class="h-app">
<a href="{{ config.SITE_URL }}" class="u-url p-name">{{ config.get('SITE_NAME', 'StarPunk') }}</a>
</div>
Reason: Not required by IndieLogin.com API
Summary of Deletions
- OAuth metadata endpoint function: ~68 lines
- h-app microformats markup: ~4 lines
- indieauth-metadata link: ~1 line
- Total removed: ~73 lines
Testing Strategy
Unit Tests
File: tests/test_auth_pkce.py (new file)
"""Tests for PKCE implementation"""
import pytest
from starpunk.auth import _generate_pkce_verifier, _generate_pkce_challenge
def test_generate_pkce_verifier():
"""Test PKCE verifier generation"""
verifier = _generate_pkce_verifier()
# Length should be 43 characters
assert len(verifier) == 43
# Should only contain URL-safe characters
assert verifier.replace('-', '').replace('_', '').isalnum()
def test_generate_pkce_verifier_unique():
"""Test that verifiers are unique"""
verifier1 = _generate_pkce_verifier()
verifier2 = _generate_pkce_verifier()
assert verifier1 != verifier2
def test_generate_pkce_challenge():
"""Test PKCE challenge generation with known values"""
# Example from RFC 7636
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = _generate_pkce_challenge(verifier)
# Expected challenge for this verifier
expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
assert challenge == expected
def test_pkce_challenge_deterministic():
"""Test that challenge is deterministic"""
verifier = _generate_pkce_verifier()
challenge1 = _generate_pkce_challenge(verifier)
challenge2 = _generate_pkce_challenge(verifier)
assert challenge1 == challenge2
def test_different_verifiers_different_challenges():
"""Test that different verifiers produce different challenges"""
verifier1 = _generate_pkce_verifier()
verifier2 = _generate_pkce_verifier()
challenge1 = _generate_pkce_challenge(verifier1)
challenge2 = _generate_pkce_challenge(verifier2)
assert challenge1 != challenge2
def test_pkce_challenge_length():
"""Test challenge is correct length"""
verifier = _generate_pkce_verifier()
challenge = _generate_pkce_challenge(verifier)
# SHA256 hash -> 32 bytes -> 43 characters base64url (no padding)
assert len(challenge) == 43
Integration Tests
Update: tests/test_auth.py
def test_initiate_login_with_pkce(app, client):
"""Test that login initiation includes PKCE parameters"""
with app.app_context():
me_url = "https://user.example.com"
auth_url = initiate_login(me_url)
# Parse URL
from urllib.parse import urlparse, parse_qs
parsed = urlparse(auth_url)
params = parse_qs(parsed.query)
# Check PKCE parameters present
assert 'code_challenge' in params
assert 'code_challenge_method' in params
assert params['code_challenge_method'][0] == 'S256'
# Check code_challenge is valid length
assert len(params['code_challenge'][0]) == 43
# Check correct endpoint
assert parsed.path == '/authorize'
def test_state_token_returns_verifier(app):
"""Test that verifying state returns code_verifier"""
with app.app_context():
db = get_db(app)
# Create state with verifier
state = "test_state_123"
verifier = "test_verifier_abc"
expires = datetime.utcnow() + timedelta(minutes=5)
db.execute(
"INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
"VALUES (?, ?, ?, ?)",
(state, verifier, expires, "http://test.com/callback")
)
db.commit()
# Verify state returns verifier
returned_verifier = _verify_state_token(state)
assert returned_verifier == verifier
# State should be deleted
result = db.execute(
"SELECT * FROM auth_state WHERE state = ?", (state,)
).fetchone()
assert result is None
def test_handle_callback_with_iss(app, mocker):
"""Test callback handling validates issuer"""
with app.app_context():
# Setup
state = "test_state"
verifier = "test_verifier"
code = "test_code"
iss = "https://indielogin.com/"
# Store state with verifier
db = get_db(app)
expires = datetime.utcnow() + timedelta(minutes=5)
db.execute(
"INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
"VALUES (?, ?, ?, ?)",
(state, verifier, expires, "http://test/callback")
)
db.commit()
# Mock HTTP response
mock_response = mocker.Mock()
mock_response.status_code = 200
mock_response.json.return_value = {"me": app.config['ADMIN_ME']}
mocker.patch('httpx.post', return_value=mock_response)
# Call with valid issuer
session_token = handle_callback(code, state, iss)
assert session_token is not None
# Verify POST included code_verifier
httpx.post.assert_called_once()
call_args = httpx.post.call_args
assert call_args[1]['data']['code_verifier'] == verifier
def test_handle_callback_invalid_issuer(app, mocker):
"""Test callback rejects invalid issuer"""
with app.app_context():
state = "test_state"
verifier = "test_verifier"
code = "test_code"
iss = "https://evil.com/" # Wrong issuer
# Store state with verifier
db = get_db(app)
expires = datetime.utcnow() + timedelta(minutes=5)
db.execute(
"INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
"VALUES (?, ?, ?, ?)",
(state, verifier, expires, "http://test/callback")
)
db.commit()
# Should raise IndieLoginError
with pytest.raises(IndieLoginError):
handle_callback(code, state, iss)
Manual Testing Checklist
Preparation:
- Database migration completed
- Code changes deployed
- Server restarted
- LOG_LEVEL=DEBUG set for detailed logs
Happy Path:
- Navigate to
/admin/login - Enter your IndieWeb identity URL
- Click "Sign In"
- Verify redirect to
https://indielogin.com/authorize - Verify URL contains
code_challengeparameter - Verify URL contains
code_challenge_method=S256 - Complete authentication on IndieLogin.com
- Verify redirect back to
/auth/callback - Verify redirect to
/admindashboard - Verify session cookie set
- Verify access to admin pages works
Error Cases:
- Invalid state token → Error message
- Expired state token → Error message
- Wrong issuer → Error message
- Invalid authorization code → Error from IndieLogin
- Wrong me URL → Unauthorized error
- Network error → Graceful error message
Security:
- State tokens single-use (can't replay)
- State tokens expire after 5 minutes
- code_verifier stored in database
- code_verifier deleted after use
- Logs show redacted tokens
- Session cookies HttpOnly
- Session cookies Secure (in production)
Error Handling
Authorization Request Errors
Invalid me_url:
if not is_valid_url(me_url):
flash("Invalid URL format", "error")
return redirect(url_for("auth.login_form"))
Database error storing state:
try:
db.execute(...)
db.commit()
except Exception as e:
current_app.logger.error(f"Failed to store auth state: {e}")
flash("Authentication initialization failed", "error")
return redirect(url_for("auth.login_form"))
Callback Errors
Missing parameters:
code = request.args.get("code")
state = request.args.get("state")
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
Invalid state:
code_verifier = _verify_state_token(state)
if not code_verifier:
flash("Invalid or expired authentication request", "error")
return redirect(url_for("auth.login_form"))
Invalid issuer:
expected_iss = f"{current_app.config['INDIELOGIN_URL']}/"
if iss and iss != expected_iss:
flash("Authentication failed: Invalid issuer", "error")
return redirect(url_for("auth.login_form"))
Token Exchange Errors
Network errors:
try:
response = httpx.post(...)
except httpx.RequestError as e:
current_app.logger.error(f"IndieLogin request failed: {e}")
flash("Authentication service unavailable", "error")
return redirect(url_for("auth.login_form"))
HTTP errors:
except httpx.HTTPStatusError as e:
current_app.logger.error(f"IndieLogin error: {e.response.status_code}")
flash("Authentication failed", "error")
return redirect(url_for("auth.login_form"))
JSON parse errors:
try:
data = response.json()
except Exception as e:
current_app.logger.error(f"Failed to parse response: {e}")
flash("Authentication failed: Invalid response", "error")
return redirect(url_for("auth.login_form"))
Missing identity:
me = data.get("me")
if not me:
flash("Authentication failed: No identity returned", "error")
return redirect(url_for("auth.login_form"))
Unauthorized user:
if me != admin_me:
current_app.logger.warning(f"Unauthorized login: {me}")
flash(f"User {me} is not authorized", "error")
return redirect(url_for("auth.login_form"))
Security Considerations
PKCE Security Properties
Prevents Authorization Code Interception:
- Attacker intercepts authorization code from URL
- Attacker cannot exchange code without
code_verifier code_challengein URL cannot be reversed (SHA256 is one-way)- Server validates
code_verifiermatches originalcode_challenge
Why PKCE is Critical:
- Mobile apps (can't secure client secret)
- Public clients (JavaScript apps, CLI tools)
- Protection against compromised redirect URIs
- Defense-in-depth even with HTTPS
State Token Security
CSRF Protection:
- Random 43+ character token
- Stored server-side with expiration
- Validated on callback
- Single-use (deleted after verification)
- Short TTL (5 minutes)
Issuer Validation
Prevents Token Substitution:
- Validates
issparameter matches expected value - Protects against malicious authorization servers
- Required by OAuth 2.0 best practices
Sensitive Data Protection
In Logs:
- Tokens redacted (show first 6-8 and last 4 characters)
code_verifiernever logged in full- HTTP request/response bodies sanitized
In Database:
code_verifierstored plaintext (needed for token exchange)- Deleted immediately after use
- Short TTL reduces exposure window
In Transit:
code_challengesent in URL (safe - can't reverse)code_verifiersent in POST body (not in URL)- HTTPS required in production
Session Security
Cookie Properties:
response.set_cookie(
"starpunk_session",
session_token,
max_age=30 * 24 * 60 * 60, # 30 days
httponly=True, # No JavaScript access
secure=True, # HTTPS only (production)
samesite="Lax" # CSRF protection
)
Session Token:
- Cryptographically random (secrets module)
- Hashed with SHA256 before storage
- 43+ characters
- Long-lived but can be revoked
Implementation Guide
Phase 1: Database Migration
Development:
# Option 1: Alter existing table
sqlite3 starpunk.db "ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';"
# Option 2: Drop and recreate (if no production data)
sqlite3 starpunk.db < schema.sql
Production (if needed):
# Backup first
cp starpunk.db starpunk.db.backup
# Add column
sqlite3 starpunk.db "ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';"
# Verify
sqlite3 starpunk.db "PRAGMA table_info(auth_state);"
Note: Existing state tokens will be invalid after deployment (they lack code_verifier). This is acceptable because:
- State tokens expire in 5 minutes
- Users mid-authentication will restart login
- Existing sessions remain valid
Phase 2: Code Changes
Step 1: Add PKCE Functions
- Open
/home/phil/Projects/starpunk/starpunk/auth.py - Add
import base64at top - Add
_generate_pkce_verifier()function after imports - Add
_generate_pkce_challenge()function after verifier function - Save file
Step 2: Update Helper Functions
- Find
_verify_state_token()function (~line 194) - Replace entire function with new version
- Find
_log_http_request()function (~line 90) - Add
code_verifierto redaction list - Save file
Step 3: Update Core Functions
- Find
initiate_login()function (~line 249) - Replace lines 268-310 with new implementation
- Find
handle_callback()function (~line 313) - Replace lines 313-404 with new implementation
- Save file
Step 4: Update Routes
- Open
/home/phil/Projects/starpunk/starpunk/routes/auth.py - Find
callback()function (~line 86) - Add
iss = request.args.get("iss") - Pass
isstohandle_callback(code, state, iss) - Update docstring
- Save file
Phase 3: Code Removal
Step 1: Remove OAuth Metadata
- Open
/home/phil/Projects/starpunk/starpunk/routes/public.py - Find
oauth_client_metadata()function (~line 150) - Delete entire function (lines 150-217)
- Save file
Step 2: Remove Template Markup
- Open
/home/phil/Projects/starpunk/templates/base.html - Find and delete indieauth-metadata link (~line 11)
- Find and delete h-app div (~lines 48-51)
- Save file
Phase 4: Testing
Unit Tests:
# Create test file
touch tests/test_auth_pkce.py
# Add PKCE tests (see Testing Strategy section)
# Run tests
uv run pytest tests/test_auth_pkce.py -v
Integration Tests:
# Update existing tests
# Add PKCE-specific test cases (see Testing Strategy section)
# Run all auth tests
uv run pytest tests/test_auth.py -v
Manual Testing:
# Start server in debug mode
export LOG_LEVEL=DEBUG
uv run python -m starpunk
# Follow manual testing checklist (see Testing Strategy section)
# Monitor logs
tail -f starpunk.log | grep "Auth:"
Phase 5: Verification
Check PKCE in Logs:
[2025-11-19 10:30:15] DEBUG - Auth: Generated PKCE pair:
verifier: abc123...********...xyz9
challenge: def456...********...uvw8
Check Database:
sqlite3 starpunk.db "SELECT state, code_verifier, expires_at FROM auth_state ORDER BY created_at DESC LIMIT 1;"
Check Authorization URL:
https://indielogin.com/authorize?
me=https://user.com&
client_id=https://starpunk.example.com&
redirect_uri=https://starpunk.example.com/auth/callback&
state=abc123xyz...&
code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
code_challenge_method=S256
Phase 6: Deployment
Pre-Deployment:
- All tests passing
- Manual testing complete
- Database migration script ready
- Rollback plan documented
Deployment Steps:
- Backup database
- Deploy code changes
- Run database migration
- Restart application
- Test authentication flow
- Monitor logs for errors
- Verify no regression in existing sessions
Post-Deployment:
- Monitor error rates
- Check authentication success rate
- Verify PKCE parameters in logs
- Test from different browsers/devices
Rollback Plan
If Implementation Fails:
-
Revert Code:
git revert <commit-hash> git push origin main -
Revert Database (optional):
sqlite3 starpunk.db "ALTER TABLE auth_state DROP COLUMN code_verifier;" -
Emergency Bypass (temporary):
# Enable dev mode to bypass auth export DEV_MODE=True export DEV_ADMIN_ME=https://your-site.com -
Restore Backup:
cp starpunk.db.backup starpunk.db systemctl restart starpunk
Success Criteria
Implementation successful when:
- User can complete login flow
- PKCE parameters in authorization URL
- code_verifier stored with state
- Callback validates state and issuer
- Token exchange includes code_verifier
- Token exchange returns identity
- Admin verification passes
- Session created successfully
- All tests pass
- No errors in production logs
Document Version: 1.0 Last Updated: 2025-11-19 Status: Ready for Implementation Related ADR: ADR-019