# 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
``` **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 ``` **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 ``` **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 ``` **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)