Files
StarPunk/docs/decisions/ADR-019-indieauth-correct-implementation.md
Phil Skentelbery 5e50330bdf 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>
2025-11-19 15:43:38 -07:00

1395 lines
43 KiB
Markdown

# 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:
1. **Generic IndieAuth specification** (full OAuth 2.0 with client discovery)
2. **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):
```python
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 string
- `code_challenge_method`: Must be `S256`
- `code_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):
```python
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):
```python
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):
```python
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**:
```python
"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-server` endpoint
- 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
```html
<!-- 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
```html
<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
1. **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.
2. **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.
3. **Not Reading IndieLogin.com API Docs First**: We should have started with https://indielogin.com/api instead of the generic spec.
4. **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 protection
- `code_challenge`: Base64-URL encoded SHA256 hash of code_verifier
- `code_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:
1. Scans user's website for `rel="me"` links
2. Shows authentication options (GitHub, Twitter, GitLab, Codeberg, email)
3. User authenticates via chosen provider
4. 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 equal `https://indielogin.com/` (SHOULD VALIDATE)
**Example**:
```
https://starpunk.thesatelliteoflove.com/auth/callback?
code=eyJ0eXAiOiJKV1QiLCJhbGc...&
state=abc123xyz789&
iss=https://indielogin.com/
```
**Our Implementation Must**:
1. Validate `state` matches our stored value (CSRF protection)
2. Validate `iss` equals `https://indielogin.com/` (issuer verification)
3. Extract `code` for 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 3
- `client_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**:
```http
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)**:
```json
{
"me": "https://user-site.com/"
}
```
**Error Response (400 Bad Request)**:
```json
{
"error": "invalid_request",
"error_description": "The code provided was not valid"
}
```
### PKCE Implementation Details
#### Generate code_verifier
```python
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
```python
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
```python
# 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**:
```sql
-- 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**:
1. Generate state + code_verifier together
2. Store BOTH in auth_state table
3. Set short expiry (5 minutes)
4. On callback: retrieve code_verifier using state
5. 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
```html
<!-- 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
```html
<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)
```python
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**:
```python
import base64 # Add to existing imports at top
```
### 2. Update Database Schema
**File**: `/home/phil/Projects/starpunk/schema.sql` (or migration file)
```sql
-- 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:
```python
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**:
1. Generate `code_verifier` and `code_challenge`
2. Store `code_verifier` in database with state
3. Send `code_challenge` and `code_challenge_method` in params
4. Remove `response_type` parameter (not needed)
5. Change endpoint from `/auth` to `/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:
```python
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**:
1. Return `code_verifier` instead of boolean
2. SELECT code_verifier from database
3. 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:
```python
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**:
1. Add `iss` parameter to function signature
2. Verify state returns `code_verifier` (not boolean)
3. Validate `iss` parameter if present
4. Include `code_verifier` in token exchange data
5. Change endpoint from `/auth` to `/token`
6. Add better error handling for JSON parsing
7. 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:
```python
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**:
1. Extract `iss` parameter from request
2. Pass `iss` to `handle_callback()`
3. Update docstring to document `iss` parameter
### 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):
```python
# 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
1. **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
2. **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
### Partially Superseded ADRs
3. **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)"
### Update Instructions
Add to top of each superseded ADR:
**ADR-016 and ADR-017**:
```markdown
## 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):
```markdown
## 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
1. **Update auth_state table schema**
```bash
# Create migration or update schema.sql
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
```
2. **Test migration**
- Run migration on dev database
- Verify column exists
- Verify existing rows have empty string default
#### Phase 2: Add PKCE Functions
1. **Add imports to auth.py**
- Add `import base64` to imports section
2. **Add PKCE helper functions**
- Add `_generate_pkce_verifier()` function
- Add `_generate_pkce_challenge()` function
- Place after imports, before existing helper functions
3. **Test PKCE functions**
```python
# 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
1. **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)
2. **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
3. **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
4. **Update `_log_http_request()` function**
- Add code_verifier to redaction list
#### Phase 4: Update Routes
1. **Update callback route**
- Extract iss parameter from request
- Pass iss to handle_callback()
- Update docstring
#### Phase 5: Remove Unnecessary Code
1. **Remove from templates/base.html**
- Delete indieauth-metadata link (line 11)
- Delete h-app microformats div (lines 48-51)
2. **Remove from routes/public.py**
- Delete oauth_client_metadata() function (lines 150-217)
- Remove route registration
#### Phase 6: Testing
1. **Unit Tests** (create `tests/test_auth_pkce.py`)
```python
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
```
2. **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
3. **Manual Testing**
```bash
# 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
1. **Update superseded ADRs**
- Add superseded notice to ADR-016
- Add superseded notice to ADR-017
- Add implementation note to ADR-005
2. **Update CHANGELOG.md**
```markdown
## [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
```
3. **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
1. Drop and recreate auth_state table (data not important in dev)
2. Update code
3. Test authentication flow
#### For Production Environments (if any exist)
1. **Database Migration**:
```sql
-- Add column with default
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
```
2. **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)
3. **Deployment Steps**:
- Deploy code changes
- Run database migration
- Restart application
- Test login flow
- Monitor logs for errors
### Rollback Plan
If implementation fails:
1. **Code Rollback**: Revert to previous commit
2. **Database Rollback**:
```sql
-- Remove column
ALTER TABLE auth_state DROP COLUMN code_verifier;
```
3. **Quick Fix**: Re-enable DEV_MODE to bypass auth temporarily
### Success Criteria
Implementation is successful when:
1. ✅ User can initiate login at /admin/login
2. ✅ Redirect to IndieLogin.com /authorize includes PKCE parameters
3. ✅ code_verifier stored in database with state
4. ✅ Callback receives code, state, and iss
5. ✅ State validation returns code_verifier
6. ✅ Issuer validation passes
7. ✅ Token exchange to /token includes code_verifier
8. ✅ Token exchange returns {"me": "user-url"}
9. ✅ Admin user verification passes
10. ✅ Session created and cookie set
11. ✅ Redirect to /admin dashboard succeeds
12. ✅ All unit tests pass
13. ✅ All integration tests pass
14. ✅ Manual testing confirms working authentication
## Rationale
### Why This Approach is Correct
1. **Based on Official API Documentation**: Every decision comes directly from https://indielogin.com/api, not generic specs
2. **PKCE is Mandatory**: IndieLogin.com requires it for security (prevents authorization code interception)
3. **Simple Authentication Flow**: IndieLogin.com provides authentication (who are you?), not authorization (what can you access?). No scopes, no access tokens - just identity.
4. **No Client Registration**: IndieLogin.com accepts any valid client_id URL. No metadata endpoint, no h-app required.
5. **Correct Endpoints**:
- `/authorize` - Start authentication (not `/auth`)
- `/token` - Exchange code for identity (not `/auth`)
6. **Security Best Practices**:
- State token prevents CSRF
- PKCE prevents code interception
- Issuer validation prevents token substitution
- Single-use tokens
### Why Previous Approaches Failed
1. **ADR-016 (h-app)**: Added client discovery mechanism that IndieLogin.com doesn't use
2. **ADR-017 (OAuth metadata)**: Added OAuth endpoint that IndieLogin.com doesn't check
3. **Original implementation**: Missing PKCE, wrong endpoints, wrong parameters
### What We Learned
1. **Read the specific API docs first**, not generic specs
2. **IndieLogin.com is not a full OAuth 2.0 server** - it's a simplified authentication service
3. **PKCE is not optional** - it's required by IndieLogin.com
4. **Authentication ≠ Authorization** - we only need identity, not access tokens
## Consequences
### Positive
1. ✅ **Authentication Will Work**: Follows IndieLogin.com API exactly
2. ✅ **Simpler Codebase**: Removes ~73 lines of unnecessary code
3. ✅ **Better Security**: PKCE prevents authorization code attacks
4. ✅ **Standards Compliant**: Implements PKCE correctly
5. ✅ **Maintainable**: Less code, clearer purpose
6. ✅ **Testable**: Well-defined flow with clear inputs/outputs
### Negative
1. ⚠️ **Database Migration Required**: Must add code_verifier column
- Mitigation: Simple ALTER TABLE, backward compatible
2. ⚠️ **Breaking Change for In-Flight Logins**: Users mid-authentication will need to restart
- Mitigation: State tokens only live 5 minutes, minimal impact
3. ⚠️ **More Complex Auth Flow**: PKCE adds steps
- Mitigation: Required by spec, improves security
### Neutral
1. **Code Complexity**: PKCE adds ~50 lines but removes ~73 lines (net -23 lines)
2. **Testing**: More test cases for PKCE, but clearer test boundaries
## Compliance
### IndieLogin.com API Compliance
- ✅ Uses `/authorize` endpoint 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 `/token` endpoint 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
1. **PKCE is non-negotiable** - don't skip it
2. **Endpoints matter** - /authorize and /token, not /auth
3. **code_verifier must be stored** - needed for step 4
4. **Validate everything** - state, iss, me URL
5. **Remove unnecessary code** - cleaner is better
### Common Pitfalls to Avoid
1. ❌ Forgetting to generate code_challenge from code_verifier
2. ❌ Not storing code_verifier in database
3. ❌ Using /auth endpoint instead of /authorize or /token
4. ❌ Not sending code_verifier in token exchange
5. ❌ Not validating iss parameter
6. ❌ Keeping unnecessary OAuth metadata code
### Debugging Tips
```bash
# 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
```python
# 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)