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>
1395 lines
43 KiB
Markdown
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)
|