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>
43 KiB
ADR-019: IndieAuth Correct Implementation Based on IndieLogin.com API
Status
Proposed
Context
StarPunk's IndieAuth authentication has been failing in production. We've implemented various fixes (ADR-016, ADR-017) including OAuth metadata endpoints and h-app microformats, but these were based on misunderstanding the requirements. This ADR provides the correct implementation based ONLY on the official IndieLogin.com API documentation at https://indielogin.com/api.
Current Failure
Users cannot authenticate. We've been adding OAuth client discovery mechanisms, but the root cause is that we're not implementing the IndieLogin.com API correctly.
What We Misunderstood
We conflated:
- Generic IndieAuth specification (full OAuth 2.0 with client discovery)
- IndieLogin.com API (simplified authentication-only service with specific requirements)
IndieLogin.com is a simplified authentication service, not a full OAuth 2.0 authorization server. It has specific API requirements that differ from the generic IndieAuth spec.
Section 1: What We Did Wrong
Critical Errors in Current Implementation
1. Missing PKCE Implementation (CRITICAL)
Current Code (starpunk/auth.py line 287-293):
params = {
"me": me_url,
"client_id": current_app.config["SITE_URL"],
"redirect_uri": redirect_uri,
"state": state,
"response_type": "code", # NOT REQUIRED by IndieLogin.com
}
What's Wrong: IndieLogin.com requires PKCE parameters:
code_challenge: Base64-URL encoded SHA256 hash of random stringcode_challenge_method: Must beS256code_verifier: Original unencoded string (sent later during token exchange)
We're not generating, storing, or sending any of these.
2. Wrong Authorization Endpoint
Current Code (starpunk/auth.py line 305):
auth_url = f"{current_app.config['INDIELOGIN_URL']}/auth?{urlencode(params)}"
What's Wrong: Should be /authorize, not /auth
Correct: https://indielogin.com/authorize
3. Wrong Token Exchange Endpoint
Current Code (starpunk/auth.py line 354-356):
response = httpx.post(
f"{current_app.config['INDIELOGIN_URL']}/auth", # WRONG ENDPOINT
data=token_exchange_data,
timeout=10.0,
)
What's Wrong: Should be /token, not /auth
Correct: https://indielogin.com/token
4. Missing code_verifier in Token Exchange
Current Code (starpunk/auth.py line 339-343):
token_exchange_data = {
"code": code,
"client_id": current_app.config["SITE_URL"],
"redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback",
# MISSING: code_verifier
}
What's Wrong: IndieLogin.com requires code_verifier parameter for PKCE validation
5. Unnecessary response_type Parameter
Current Code:
"response_type": "code",
What's Wrong: IndieLogin.com API docs don't mention this parameter. It's not needed for the authentication flow.
6. Missing iss Validation
Current Code (starpunk/auth.py line 313-404):
No validation of iss parameter in callback
What's Wrong: IndieLogin.com returns iss=https://indielogin.com/ parameter in callback. We should validate it matches before proceeding.
Unnecessary Features We Added
1. OAuth Metadata Endpoint (NOT NEEDED)
File: starpunk/routes/public.py lines 150-217
Why We Added It: ADR-017 proposed this based on generic OAuth 2.0 / IndieAuth spec requirements for client discovery
Why It's Not Needed: IndieLogin.com API documentation makes NO mention of:
- Client registration
- Client metadata discovery
/.well-known/oauth-authorization-serverendpoint- JSON metadata documents
IndieLogin.com accepts ANY valid client_id URL without pre-registration.
2. h-app Microformats (NOT NEEDED)
File: templates/base.html 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>
Why We Added It: ADR-016 proposed this for client discovery
Why It's Not Needed: IndieLogin.com API docs don't require or mention h-app microformats. The service works with the client_id URL directly.
3. indieauth-metadata Link (NOT NEEDED)
File: templates/base.html line 11
<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">
Why It's Not Needed: Same as above - not required by IndieLogin.com API
What Misled Us
-
Reading Generic IndieAuth Spec: We read the full IndieAuth specification which describes client discovery, authorization servers, etc. IndieLogin.com is a simplified service that doesn't require all of this.
-
Assuming OAuth 2.0 Full Compliance: We assumed IndieLogin.com needed OAuth 2.0 client registration. It doesn't - it's authentication-only, not authorization.
-
Not Reading IndieLogin.com API Docs First: We should have started with https://indielogin.com/api instead of the generic spec.
-
Confusing Authentication vs Authorization: IndieLogin.com provides authentication (who are you?) not authorization (what can you access?). No scopes, no access tokens for API access - just identity verification.
Section 2: The Correct Approach
Authentication vs Authorization
IndieLogin.com provides AUTHENTICATION ONLY
We need: Web Sign-In Flow (heading "1. Create a Web Sign In Form" in API docs)
We do NOT need: Authorization flow with scopes (that's for Micropub later)
The Correct 4-Step Flow
Step 1: Create Authorization Request with PKCE
POST/GET to: https://indielogin.com/authorize
Required Parameters:
client_id: Our application URL (e.g.,https://starpunk.thesatelliteoflove.com)redirect_uri: Our callback URL (must be on same domain as client_id)state: Random value for CSRF protectioncode_challenge: Base64-URL encoded SHA256 hash of code_verifiercode_challenge_method:S256
Optional Parameters:
me: Pre-fill user's URL (prompt if omitted)prompt=login: Force fresh authentication
Example:
https://indielogin.com/authorize?
client_id=https://starpunk.thesatelliteoflove.com&
redirect_uri=https://starpunk.thesatelliteoflove.com/auth/callback&
state=abc123xyz789&
code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
code_challenge_method=S256&
me=https://user-site.com
Step 2: User Authentication (Handled by IndieLogin.com)
IndieLogin.com:
- Scans user's website for
rel="me"links - Shows authentication options (GitHub, Twitter, GitLab, Codeberg, email)
- User authenticates via chosen provider
- IndieLogin.com verifies identity
We don't implement anything for this step - it's all handled by IndieLogin.com
Step 3: Handle Redirect Callback
IndieLogin.com redirects to our redirect_uri with:
code: Authorization code (to exchange for identity)state: Our original state value (MUST VALIDATE)iss: Should equalhttps://indielogin.com/(SHOULD VALIDATE)
Example:
https://starpunk.thesatelliteoflove.com/auth/callback?
code=eyJ0eXAiOiJKV1QiLCJhbGc...&
state=abc123xyz789&
iss=https://indielogin.com/
Our Implementation Must:
- Validate
statematches our stored value (CSRF protection) - Validate
issequalshttps://indielogin.com/(issuer verification) - Extract
codefor next step
Step 4: Exchange Code for Identity
POST to: https://indielogin.com/token
Content-Type: application/x-www-form-urlencoded
Required Parameters:
code: The authorization code from step 3client_id: Our application URL (same as step 1)redirect_uri: Our callback URL (same as step 1)code_verifier: The original random string (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.thesatelliteoflove.com&
redirect_uri=https://starpunk.thesatelliteoflove.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"
}
PKCE Implementation Details
Generate code_verifier
import secrets
import base64
import hashlib
def generate_pkce_verifier() -> str:
"""
Generate PKCE code_verifier.
Returns:
Random 43-128 character URL-safe string
"""
# Generate 32 random bytes = 43 chars when base64-url encoded
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')
# Remove padding
return verifier.rstrip('=')
Generate code_challenge from code_verifier
def generate_pkce_challenge(verifier: str) -> str:
"""
Generate PKCE code_challenge from verifier.
Args:
verifier: The code_verifier string
Returns:
Base64-URL encoded SHA256 hash of verifier
"""
# SHA256 hash the verifier
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
# Base64-URL encode
challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
# Remove padding
return challenge.rstrip('=')
Example Usage
# Step 1: Generate and store
verifier = generate_pkce_verifier()
# Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = generate_pkce_challenge(verifier)
# Example: "K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U"
# Store verifier in database with state token (needed for step 4)
db.execute(
"INSERT INTO auth_state (state, code_verifier, expires_at) VALUES (?, ?, ?)",
(state, verifier, expires_at)
)
# Step 2: Send challenge in authorization request
params = {
'client_id': SITE_URL,
'redirect_uri': f'{SITE_URL}/auth/callback',
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
'me': me_url # optional
}
# Step 3: After callback, retrieve verifier
row = db.execute("SELECT code_verifier FROM auth_state WHERE state = ?", (state,)).fetchone()
verifier = row['code_verifier']
# Step 4: Send verifier in token exchange
token_data = {
'code': code,
'client_id': SITE_URL,
'redirect_uri': f'{SITE_URL}/auth/callback',
'code_verifier': verifier
}
Session Management for code_verifier
Database Schema Update Required:
-- Current schema
CREATE TABLE auth_state (
state TEXT PRIMARY KEY,
expires_at TIMESTAMP NOT NULL,
redirect_uri TEXT NOT NULL
);
-- NEW: Add code_verifier column
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL;
Storage Flow:
- Generate state + code_verifier together
- Store BOTH in auth_state table
- Set short expiry (5 minutes)
- On callback: retrieve code_verifier using state
- Delete state row after use (single-use)
Complete Flow Diagram
┌─────────────┐
│ User clicks │
│ "Login" │
└──────┬──────┘
│
▼
┌─────────────────────────────────────┐
│ StarPunk: initiate_login() │
│ 1. Generate state token │
│ 2. Generate code_verifier (random) │
│ 3. Generate code_challenge (SHA256) │
│ 4. Store state + verifier in DB │
│ 5. Redirect to IndieLogin.com │
└──────┬──────────────────────────────┘
│
│ Redirect to /authorize with:
│ - 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 │
│ 4. User authenticates │
│ 5. Verifies identity │
└──────┬──────────────────────────────┘
│
│ Redirect back with:
│ - code, state, iss
│
▼
┌─────────────────────────────────────┐
│ StarPunk: handle_callback() │
│ 1. Validate state matches │
│ 2. Validate iss = indielogin.com │
│ 3. Retrieve code_verifier from DB │
│ 4. POST to /token with verifier │
│ 5. Receive {"me": "user-url"} │
│ 6. Verify me == ADMIN_ME │
│ 7. Create session │
│ 8. Set session cookie │
│ 9. Redirect to /admin │
└──────┬──────────────────────────────┘
│
▼
┌─────────────┐
│ Logged In │
└─────────────┘
Error Handling
During Authorization Request:
- Validate me URL format
- Ensure HTTPS in production
- Handle DB errors storing state
During Callback:
- Missing code/state/iss → Error page "Authentication failed"
- Invalid state → Error "CSRF token invalid" (security error)
- iss mismatch → Error "Invalid issuer" (security error)
- State not found in DB → Error "State expired or invalid"
- Code verifier not found → Error "Authentication state lost"
During Token Exchange:
- Network errors → Retry once, then error "IndieLogin.com unavailable"
- 400 response → Error "Invalid authorization code"
- Missing "me" in response → Error "No identity returned"
- me != ADMIN_ME → Error "Unauthorized user"
- JSON parse error → Error "Invalid response from IndieLogin.com"
Section 3: Code to Remove
1. Remove OAuth Metadata Endpoint
File: /home/phil/Projects/starpunk/starpunk/routes/public.py
Lines to DELETE: 150-217 (entire oauth_client_metadata() function)
Route to Remove: /.well-known/oauth-authorization-server
Reason: Not required by IndieLogin.com API. Adds unnecessary complexity.
2. Remove h-app Microformats
File: /home/phil/Projects/starpunk/templates/base.html
Lines to DELETE: 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
3. Remove indieauth-metadata Link
File: /home/phil/Projects/starpunk/templates/base.html
Line to DELETE: 11
<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">
Reason: Not required by IndieLogin.com API
Summary of Deletions
- DELETE:
oauth_client_metadata()function and route (~68 lines) - DELETE: h-app microformats HTML (~4 lines)
- DELETE: indieauth-metadata link (~1 line)
- TOTAL: ~73 lines of unnecessary code removed
Section 4: Code to Add/Modify
1. Add PKCE Functions
File: /home/phil/Projects/starpunk/starpunk/auth.py
Location: After imports, before helper functions (around line 43)
def _generate_pkce_verifier() -> str:
"""
Generate PKCE code_verifier.
Creates a cryptographically random 43-character URL-safe string
as required by PKCE specification.
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.
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 (secrets.token_urlsafe style, no padding)
challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
return challenge
Additional Import Required:
import base64 # Add to existing imports at top
2. Update Database Schema
File: /home/phil/Projects/starpunk/schema.sql (or migration file)
-- Add code_verifier column to auth_state table
-- This stores the PKCE verifier for the token exchange step
-- If using migration:
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
-- If recreating table (dev mode):
DROP TABLE IF EXISTS auth_state;
CREATE TABLE auth_state (
state TEXT PRIMARY KEY,
code_verifier TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
redirect_uri TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
3. Update initiate_login() Function
File: /home/phil/Projects/starpunk/starpunk/auth.py
Function: initiate_login() (starting 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 - Send
code_challengeandcode_challenge_methodin params - Remove
response_typeparameter (not needed) - Change endpoint from
/authto/authorize
4. Update _verify_state_token() Function
File: /home/phil/Projects/starpunk/starpunk/auth.py
Function: _verify_state_token() (starting line 194)
REPLACE entire function 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 otherwise
"""
db = get_db(current_app)
# Check if state exists and not expired, also 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
code_verifierinstead of boolean - SELECT code_verifier from database
- Return None if invalid (instead of False)
5. Update handle_callback() Function
File: /home/phil/Projects/starpunk/starpunk/auth.py
Function: handle_callback() (starting 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 returns
code_verifier(not boolean) - Validate
issparameter if present - Include
code_verifierin token exchange data - Change endpoint from
/authto/token - Add better error handling for JSON parsing
- Update logging to show code_verifier (will be redacted)
6. Update Callback Route
File: /home/phil/Projects/starpunk/starpunk/routes/auth.py
Function: callback() (starting line 86)
REPLACE lines 104-113 with:
def callback():
"""
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 = request.args.get("code")
state = request.args.get("state")
iss = request.args.get("iss") # NEW: Get issuer parameter
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
try:
# Handle callback with PKCE verification
session_token = handle_callback(code, state, iss) # Pass iss parameter
# ... rest of function unchanged ...
Key Changes:
- Extract
issparameter from request - Pass
isstohandle_callback() - Update docstring to document
issparameter
7. Update Logging Helper
File: /home/phil/Projects/starpunk/starpunk/auth.py
Function: _log_http_request() (line 90)
ADD code_verifier to redaction list (line 105-110):
# 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"])
Section 5: Superseded Documentation
The following ADRs and decisions are SUPERSEDED by this ADR:
Superseded ADRs
-
ADR-016: IndieAuth Client Discovery Mechanism
- File:
/home/phil/Projects/starpunk/docs/decisions/ADR-016-indieauth-client-discovery.md - Status: Change to "Superseded by ADR-019"
- Reason: h-app microformats not required by IndieLogin.com API
- Action: Update status field in document
- File:
-
ADR-017: OAuth Client ID Metadata Document Implementation
- File:
/home/phil/Projects/starpunk/docs/decisions/ADR-017-oauth-client-metadata-document.md - Status: Change to "Superseded by ADR-019"
- Reason: OAuth metadata endpoint not required by IndieLogin.com API
- Action: Update status field in document
- File:
Partially Superseded ADRs
- ADR-005: IndieLogin Authentication Integration
- File:
/home/phil/Projects/starpunk/docs/decisions/ADR-005-indielogin-authentication.md - Status: Remains "Accepted" but needs correction note
- Reason: Flow was correct in concept but implementation details were wrong (missing PKCE, wrong endpoints)
- Action: Add note at top: "NOTE: Implementation corrected in ADR-019 (PKCE, endpoints)"
- File:
Update Instructions
Add to top of each superseded ADR:
ADR-016 and ADR-017:
## Status
Superseded by ADR-019
**Note**: This ADR describes features (h-app microformats, OAuth metadata endpoint) that were added based on generic IndieAuth specification but are not required by IndieLogin.com API. See ADR-019 for correct implementation based on actual IndieLogin.com requirements.
ADR-005 (add note below Status):
## Status
Accepted
**Implementation Note**: The authentication flow described here is conceptually correct, but the implementation details were corrected in ADR-019, specifically:
- Added mandatory PKCE support (code_challenge/code_verifier)
- Corrected endpoints (/authorize instead of /auth, /token for exchange)
- Added issuer validation
- Removed unnecessary response_type parameter
Section 6: Implementation Plan
Step-by-Step Implementation Guide
Phase 1: Database Migration
-
Update auth_state table schema
# Create migration or update schema.sql ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT ''; -
Test migration
- Run migration on dev database
- Verify column exists
- Verify existing rows have empty string default
Phase 2: Add PKCE Functions
-
Add imports to auth.py
- Add
import base64to imports section
- Add
-
Add PKCE helper functions
- Add
_generate_pkce_verifier()function - Add
_generate_pkce_challenge()function - Place after imports, before existing helper functions
- Add
-
Test PKCE functions
# Manual test in Python REPL verifier = _generate_pkce_verifier() assert len(verifier) == 43 challenge = _generate_pkce_challenge(verifier) assert len(challenge) == 43 assert challenge != verifier
Phase 3: Update Core Auth Functions
-
Update
_verify_state_token()function- Change return type from bool to Optional[str]
- SELECT and return code_verifier
- Update all callers (only handle_callback uses it)
-
Update
initiate_login()function- Generate code_verifier and code_challenge
- Store code_verifier in database
- Add PKCE params to authorization URL
- Remove response_type param
- Change endpoint to /authorize
-
Update
handle_callback()function- Add iss parameter
- Receive code_verifier from _verify_state_token
- Validate iss parameter
- Include code_verifier in token exchange
- Change endpoint to /token
- Improve error handling
-
Update
_log_http_request()function- Add code_verifier to redaction list
Phase 4: Update Routes
- Update callback route
- Extract iss parameter from request
- Pass iss to handle_callback()
- Update docstring
Phase 5: Remove Unnecessary Code
-
Remove from templates/base.html
- Delete indieauth-metadata link (line 11)
- Delete h-app microformats div (lines 48-51)
-
Remove from routes/public.py
- Delete oauth_client_metadata() function (lines 150-217)
- Remove route registration
Phase 6: Testing
-
Unit Tests (create
tests/test_auth_pkce.py)def test_generate_pkce_verifier(): verifier = _generate_pkce_verifier() assert len(verifier) == 43 assert verifier.replace('-', '').replace('_', '').isalnum() def test_generate_pkce_challenge(): verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" challenge = _generate_pkce_challenge(verifier) # Expected value from PKCE spec example assert challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" def test_pkce_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(): verifier1 = _generate_pkce_verifier() verifier2 = _generate_pkce_verifier() challenge1 = _generate_pkce_challenge(verifier1) challenge2 = _generate_pkce_challenge(verifier2) assert challenge1 != challenge2 -
Integration Tests (update existing tests)
- Mock IndieLogin.com responses
- Test full flow with PKCE
- Test state + verifier storage/retrieval
- Test verifier validation
- Test iss validation
-
Manual Testing
# 1. Start dev server uv run python -m starpunk # 2. Navigate to /admin/login # 3. Enter your website URL # 4. Complete IndieLogin.com authentication # 5. Verify successful redirect to /admin # 6. Check logs for PKCE parameters
Phase 7: Update Documentation
-
Update superseded ADRs
- Add superseded notice to ADR-016
- Add superseded notice to ADR-017
- Add implementation note to ADR-005
-
Update CHANGELOG.md
## [0.6.3] - 2025-11-19 ### Fixed - IndieAuth authentication now works correctly with IndieLogin.com - Added required PKCE (Proof Key for Code Exchange) support - Corrected IndieLogin.com API endpoints (/authorize and /token) - Added issuer validation for security ### Removed - Unnecessary OAuth metadata endpoint (/.well-known/oauth-authorization-server) - Unnecessary h-app microformats markup - Unnecessary indieauth-metadata link ### Changed - auth_state database table now includes code_verifier column -
Increment version
- Update version to 0.6.3 (patch - critical bug fix)
Testing Strategy
Manual Testing Checklist
- Login form displays at /admin/login
- Entering URL redirects to IndieLogin.com
- IndieLogin.com shows authentication options
- Completing auth redirects back to StarPunk
- Callback validates state correctly
- Callback validates iss correctly
- Token exchange succeeds with code_verifier
- Session created successfully
- Redirected to /admin dashboard
- Session persists across requests
- Logout destroys session
Error Case Testing
- Invalid state token → Error message
- Expired state token → Error message
- Wrong iss value → Error message
- Invalid code → Error from IndieLogin.com
- Wrong me URL → Unauthorized error
- Network error during token exchange → Graceful error
Security Testing
- State tokens are single-use
- State tokens expire after 5 minutes
- code_verifier stored securely
- code_verifier deleted after use
- Session cookies are HttpOnly
- Session cookies are Secure (in production)
- CSRF protection works
Migration Notes
For Development Environments
- Drop and recreate auth_state table (data not important in dev)
- Update code
- Test authentication flow
For Production Environments (if any exist)
-
Database Migration:
-- Add column with default ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT ''; -
Note: Existing state tokens in database will be invalid after deploy
- State tokens expire in 5 minutes anyway
- Users mid-authentication will need to restart login
- Existing sessions remain valid (no impact to logged-in users)
-
Deployment Steps:
- Deploy code changes
- Run database migration
- Restart application
- Test login flow
- Monitor logs for errors
Rollback Plan
If implementation fails:
- Code Rollback: Revert to previous commit
- Database Rollback:
-- Remove column ALTER TABLE auth_state DROP COLUMN code_verifier; - Quick Fix: Re-enable DEV_MODE to bypass auth temporarily
Success Criteria
Implementation is successful when:
- ✅ User can initiate login at /admin/login
- ✅ Redirect to IndieLogin.com /authorize includes PKCE parameters
- ✅ code_verifier stored in database with state
- ✅ Callback receives code, state, and iss
- ✅ State validation returns code_verifier
- ✅ Issuer validation passes
- ✅ Token exchange to /token includes code_verifier
- ✅ Token exchange returns {"me": "user-url"}
- ✅ Admin user verification passes
- ✅ Session created and cookie set
- ✅ Redirect to /admin dashboard succeeds
- ✅ All unit tests pass
- ✅ All integration tests pass
- ✅ Manual testing confirms working authentication
Rationale
Why This Approach is Correct
-
Based on Official API Documentation: Every decision comes directly from https://indielogin.com/api, not generic specs
-
PKCE is Mandatory: IndieLogin.com requires it for security (prevents authorization code interception)
-
Simple Authentication Flow: IndieLogin.com provides authentication (who are you?), not authorization (what can you access?). No scopes, no access tokens - just identity.
-
No Client Registration: IndieLogin.com accepts any valid client_id URL. No metadata endpoint, no h-app required.
-
Correct Endpoints:
/authorize- Start authentication (not/auth)/token- Exchange code for identity (not/auth)
-
Security Best Practices:
- State token prevents CSRF
- PKCE prevents code interception
- Issuer validation prevents token substitution
- Single-use tokens
Why Previous Approaches Failed
- ADR-016 (h-app): Added client discovery mechanism that IndieLogin.com doesn't use
- ADR-017 (OAuth metadata): Added OAuth endpoint that IndieLogin.com doesn't check
- Original implementation: Missing PKCE, wrong endpoints, wrong parameters
What We Learned
- Read the specific API docs first, not generic specs
- IndieLogin.com is not a full OAuth 2.0 server - it's a simplified authentication service
- PKCE is not optional - it's required by IndieLogin.com
- Authentication ≠ Authorization - we only need identity, not access tokens
Consequences
Positive
- ✅ Authentication Will Work: Follows IndieLogin.com API exactly
- ✅ Simpler Codebase: Removes ~73 lines of unnecessary code
- ✅ Better Security: PKCE prevents authorization code attacks
- ✅ Standards Compliant: Implements PKCE correctly
- ✅ Maintainable: Less code, clearer purpose
- ✅ Testable: Well-defined flow with clear inputs/outputs
Negative
- ⚠️ Database Migration Required: Must add code_verifier column
- Mitigation: Simple ALTER TABLE, backward compatible
- ⚠️ Breaking Change for In-Flight Logins: Users mid-authentication will need to restart
- Mitigation: State tokens only live 5 minutes, minimal impact
- ⚠️ More Complex Auth Flow: PKCE adds steps
- Mitigation: Required by spec, improves security
Neutral
- Code Complexity: PKCE adds ~50 lines but removes ~73 lines (net -23 lines)
- Testing: More test cases for PKCE, but clearer test boundaries
Compliance
IndieLogin.com API Compliance
- ✅ Uses
/authorizeendpoint for authentication - ✅ Sends required parameters: client_id, redirect_uri, state
- ✅ Sends PKCE parameters: code_challenge, code_challenge_method
- ✅ Validates state in callback
- ✅ Validates iss in callback
- ✅ Uses
/tokenendpoint for code exchange - ✅ Sends code_verifier in token exchange
- ✅ Handles success response {"me": "url"}
- ✅ Handles error response {"error": "...", "error_description": "..."}
PKCE Specification Compliance
- ✅ code_verifier: 43-128 character random string
- ✅ code_challenge: Base64-URL encoded SHA256 hash
- ✅ code_challenge_method: S256
- ✅ Verifier sent in token exchange
- ✅ Challenge sent in authorization request
Project Standards Compliance
- ✅ Minimal code (removes unnecessary features)
- ✅ Standards-first (follows official API)
- ✅ Security best practices (PKCE, state, iss validation)
- ✅ "Every line must justify existence" (removes 73 lines that didn't)
References
Primary Source
- IndieLogin.com API Documentation: https://indielogin.com/api
- This is the ONLY source for implementation decisions in this ADR
Supporting Specifications
- PKCE Specification (RFC 7636): https://www.rfc-editor.org/rfc/rfc7636
- OAuth 2.0 (RFC 6749): https://www.rfc-editor.org/rfc/rfc6749
- IndieAuth Specification: https://indieauth.spec.indieweb.org/ (for context only)
Internal Documentation
- ADR-005: IndieLogin Authentication Integration (conceptual flow)
- ADR-016: IndieAuth Client Discovery Mechanism (superseded)
- ADR-017: OAuth Client ID Metadata Document (superseded)
Related ADRs
- Supersedes: ADR-016, ADR-017
- Corrects: ADR-005 (implementation details)
- Relates to: ADR-010 (Authentication Module Design)
Version Impact
Issue Type: Critical Bug Fix (authentication completely broken)
Version Change: v0.6.2 → v0.6.3
Semantic Versioning: Patch increment (fixes broken functionality, no new features)
Changelog Category: Fixed
Notes for Developer
Key Implementation Points
- PKCE is non-negotiable - don't skip it
- Endpoints matter - /authorize and /token, not /auth
- code_verifier must be stored - needed for step 4
- Validate everything - state, iss, me URL
- Remove unnecessary code - cleaner is better
Common Pitfalls to Avoid
- ❌ Forgetting to generate code_challenge from code_verifier
- ❌ Not storing code_verifier in database
- ❌ Using /auth endpoint instead of /authorize or /token
- ❌ Not sending code_verifier in token exchange
- ❌ Not validating iss parameter
- ❌ Keeping unnecessary OAuth metadata code
Debugging Tips
# Check database for code_verifier storage
sqlite3 starpunk.db "SELECT state, code_verifier, expires_at FROM auth_state;"
# Watch logs during authentication
tail -f starpunk.log | grep "Auth:"
# Test PKCE generation
python3 -c "
from starpunk.auth import _generate_pkce_verifier, _generate_pkce_challenge
v = _generate_pkce_verifier()
c = _generate_pkce_challenge(v)
print(f'Verifier: {v}')
print(f'Challenge: {c}')
"
Testing the Implementation
# In Python REPL or test file
import requests
# Step 1: Check authorization URL
url = "http://localhost:5000/admin/login"
# Follow redirect, capture URL
# Should contain: code_challenge, code_challenge_method=S256
# Step 2: Check database
import sqlite3
conn = sqlite3.connect('starpunk.db')
cursor = conn.execute("SELECT * FROM auth_state ORDER BY created_at DESC LIMIT 1")
row = cursor.fetchone()
print(row) # Should show state and code_verifier
# Step 3: Mock callback
# GET /auth/callback?code=XXX&state=YYY&iss=https://indielogin.com/
# Step 4: Check logs
# Should show POST to /token with code_verifier
Decided: 2025-11-19 Author: StarPunk Architect Agent Supersedes: ADR-016, ADR-017 Corrects: ADR-005 (implementation) Status: Proposed (awaiting user approval before implementation)