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

43 KiB

ADR-019: IndieAuth Correct Implementation Based on IndieLogin.com API

Status

Proposed

Context

StarPunk's IndieAuth authentication has been failing in production. We've implemented various fixes (ADR-016, ADR-017) including OAuth metadata endpoints and h-app microformats, but these were based on misunderstanding the requirements. This ADR provides the correct implementation based ONLY on the official IndieLogin.com API documentation at https://indielogin.com/api.

Current Failure

Users cannot authenticate. We've been adding OAuth client discovery mechanisms, but the root cause is that we're not implementing the IndieLogin.com API correctly.

What We Misunderstood

We conflated:

  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):

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):

auth_url = f"{current_app.config['INDIELOGIN_URL']}/auth?{urlencode(params)}"

What's Wrong: Should be /authorize, not /auth

Correct: https://indielogin.com/authorize

3. Wrong Token Exchange Endpoint

Current Code (starpunk/auth.py line 354-356):

response = httpx.post(
    f"{current_app.config['INDIELOGIN_URL']}/auth",  # WRONG ENDPOINT
    data=token_exchange_data,
    timeout=10.0,
)

What's Wrong: Should be /token, not /auth

Correct: https://indielogin.com/token

4. Missing code_verifier in Token Exchange

Current Code (starpunk/auth.py line 339-343):

token_exchange_data = {
    "code": code,
    "client_id": current_app.config["SITE_URL"],
    "redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback",
    # MISSING: code_verifier
}

What's Wrong: IndieLogin.com requires code_verifier parameter for PKCE validation

5. Unnecessary response_type Parameter

Current Code:

"response_type": "code",

What's Wrong: IndieLogin.com API docs don't mention this parameter. It's not needed for the authentication flow.

6. Missing iss Validation

Current Code (starpunk/auth.py line 313-404): No validation of iss parameter in callback

What's Wrong: IndieLogin.com returns iss=https://indielogin.com/ parameter in callback. We should validate it matches before proceeding.

Unnecessary Features We Added

1. OAuth Metadata Endpoint (NOT NEEDED)

File: starpunk/routes/public.py lines 150-217

Why We Added It: ADR-017 proposed this based on generic OAuth 2.0 / IndieAuth spec requirements for client discovery

Why It's Not Needed: IndieLogin.com API documentation makes NO mention of:

  • Client registration
  • Client metadata discovery
  • /.well-known/oauth-authorization-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

<!-- 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.

File: templates/base.html line 11

<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">

Why It's Not Needed: Same as above - not required by IndieLogin.com API

What Misled Us

  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:

POST /token HTTP/1.1
Host: indielogin.com
Content-Type: application/x-www-form-urlencoded

code=eyJ0eXAiOiJKV1QiLCJhbGc...&
client_id=https://starpunk.thesatelliteoflove.com&
redirect_uri=https://starpunk.thesatelliteoflove.com/auth/callback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Success Response (200 OK):

{
  "me": "https://user-site.com/"
}

Error Response (400 Bad Request):

{
  "error": "invalid_request",
  "error_description": "The code provided was not valid"
}

PKCE Implementation Details

Generate code_verifier

import secrets
import base64
import hashlib

def generate_pkce_verifier() -> str:
    """
    Generate PKCE code_verifier.

    Returns:
        Random 43-128 character URL-safe string
    """
    # Generate 32 random bytes = 43 chars when base64-url encoded
    verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8')
    # Remove padding
    return verifier.rstrip('=')

Generate code_challenge from code_verifier

def generate_pkce_challenge(verifier: str) -> str:
    """
    Generate PKCE code_challenge from verifier.

    Args:
        verifier: The code_verifier string

    Returns:
        Base64-URL encoded SHA256 hash of verifier
    """
    # SHA256 hash the verifier
    digest = hashlib.sha256(verifier.encode('utf-8')).digest()
    # Base64-URL encode
    challenge = base64.urlsafe_b64encode(digest).decode('utf-8')
    # Remove padding
    return challenge.rstrip('=')

Example Usage

# Step 1: Generate and store
verifier = generate_pkce_verifier()
# Example: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"

challenge = generate_pkce_challenge(verifier)
# Example: "K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U"

# Store verifier in database with state token (needed for step 4)
db.execute(
    "INSERT INTO auth_state (state, code_verifier, expires_at) VALUES (?, ?, ?)",
    (state, verifier, expires_at)
)

# Step 2: Send challenge in authorization request
params = {
    'client_id': SITE_URL,
    'redirect_uri': f'{SITE_URL}/auth/callback',
    'state': state,
    'code_challenge': challenge,
    'code_challenge_method': 'S256',
    'me': me_url  # optional
}

# Step 3: After callback, retrieve verifier
row = db.execute("SELECT code_verifier FROM auth_state WHERE state = ?", (state,)).fetchone()
verifier = row['code_verifier']

# Step 4: Send verifier in token exchange
token_data = {
    'code': code,
    'client_id': SITE_URL,
    'redirect_uri': f'{SITE_URL}/auth/callback',
    'code_verifier': verifier
}

Session Management for code_verifier

Database Schema Update Required:

-- Current schema
CREATE TABLE auth_state (
    state TEXT PRIMARY KEY,
    expires_at TIMESTAMP NOT NULL,
    redirect_uri TEXT NOT NULL
);

-- NEW: Add code_verifier column
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL;

Storage Flow:

  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

<!-- 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

File: /home/phil/Projects/starpunk/templates/base.html

Line to DELETE: 11

<link rel="indieauth-metadata" href="/.well-known/oauth-authorization-server">

Reason: Not required by IndieLogin.com API

Summary of Deletions

  • DELETE: oauth_client_metadata() function and route (~68 lines)
  • DELETE: h-app microformats HTML (~4 lines)
  • DELETE: indieauth-metadata link (~1 line)
  • TOTAL: ~73 lines of unnecessary code removed

Section 4: Code to Add/Modify

1. Add PKCE Functions

File: /home/phil/Projects/starpunk/starpunk/auth.py

Location: After imports, before helper functions (around line 43)

def _generate_pkce_verifier() -> str:
    """
    Generate PKCE code_verifier.

    Creates a cryptographically random 43-character URL-safe string
    as required by PKCE specification.

    Returns:
        URL-safe base64-encoded random string (43 characters)
    """
    # Generate 32 random bytes = 43 chars when base64-url encoded
    verifier = secrets.token_urlsafe(32)
    return verifier


def _generate_pkce_challenge(verifier: str) -> str:
    """
    Generate PKCE code_challenge from code_verifier.

    Creates SHA256 hash of verifier and encodes as base64-url string.

    Args:
        verifier: The code_verifier string from _generate_pkce_verifier()

    Returns:
        Base64-URL encoded SHA256 hash (43 characters)
    """
    # SHA256 hash the verifier
    digest = hashlib.sha256(verifier.encode('utf-8')).digest()
    # Base64-URL encode (secrets.token_urlsafe style, no padding)
    challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
    return challenge

Additional Import Required:

import base64  # Add to existing imports at top

2. Update Database Schema

File: /home/phil/Projects/starpunk/schema.sql (or migration file)

-- Add code_verifier column to auth_state table
-- This stores the PKCE verifier for the token exchange step

-- If using migration:
ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';

-- If recreating table (dev mode):
DROP TABLE IF EXISTS auth_state;
CREATE TABLE auth_state (
    state TEXT PRIMARY KEY,
    code_verifier TEXT NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    redirect_uri TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

3. Update initiate_login() Function

File: /home/phil/Projects/starpunk/starpunk/auth.py

Function: initiate_login() (starting line 249)

REPLACE lines 268-310 with:

def initiate_login(me_url: str) -> str:
    """
    Initiate IndieLogin authentication flow with PKCE.

    Args:
        me_url: User's IndieWeb identity URL

    Returns:
        Redirect URL to IndieLogin.com

    Raises:
        ValueError: Invalid me_url format
    """
    # Validate URL format
    if not is_valid_url(me_url):
        raise ValueError(f"Invalid URL format: {me_url}")

    current_app.logger.debug(f"Auth: Validating me URL: {me_url}")

    # Generate CSRF state token
    state = _generate_state_token()
    current_app.logger.debug(f"Auth: Generated state token: {_redact_token(state, 8)}")

    # Generate PKCE verifier and challenge
    code_verifier = _generate_pkce_verifier()
    code_challenge = _generate_pkce_challenge(code_verifier)
    current_app.logger.debug(
        f"Auth: Generated PKCE pair:\n"
        f"  verifier: {_redact_token(code_verifier)}\n"
        f"  challenge: {_redact_token(code_challenge)}"
    )

    # Store state and verifier in database (5-minute expiry)
    db = get_db(current_app)
    expires_at = datetime.utcnow() + timedelta(minutes=5)
    redirect_uri = f"{current_app.config['SITE_URL']}/auth/callback"

    db.execute(
        """
        INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri)
        VALUES (?, ?, ?, ?)
        """,
        (state, code_verifier, expires_at, redirect_uri),
    )
    db.commit()

    # Build IndieLogin authorization URL with PKCE
    params = {
        "me": me_url,
        "client_id": current_app.config["SITE_URL"],
        "redirect_uri": redirect_uri,
        "state": state,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
    }

    current_app.logger.debug(
        f"Auth: Building authorization URL with params:\n"
        f"  me: {me_url}\n"
        f"  client_id: {current_app.config['SITE_URL']}\n"
        f"  redirect_uri: {redirect_uri}\n"
        f"  state: {_redact_token(state, 8)}\n"
        f"  code_challenge: {_redact_token(code_challenge)}\n"
        f"  code_challenge_method: S256"
    )

    # CORRECT ENDPOINT: /authorize (not /auth)
    auth_url = f"{current_app.config['INDIELOGIN_URL']}/authorize?{urlencode(params)}"

    current_app.logger.info(f"Auth: Authentication initiated for {me_url}")

    return auth_url

Key Changes:

  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:

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:

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:

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):

# 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

  1. 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:

## Status

Superseded by ADR-019

**Note**: This ADR describes features (h-app microformats, OAuth metadata endpoint) that were added based on generic IndieAuth specification but are not required by IndieLogin.com API. See ADR-019 for correct implementation based on actual IndieLogin.com requirements.

ADR-005 (add note below Status):

## Status

Accepted

**Implementation Note**: The authentication flow described here is conceptually correct, but the implementation details were corrected in ADR-019, specifically:
- Added mandatory PKCE support (code_challenge/code_verifier)
- Corrected endpoints (/authorize instead of /auth, /token for exchange)
- Added issuer validation
- Removed unnecessary response_type parameter

Section 6: Implementation Plan

Step-by-Step Implementation Guide

Phase 1: Database Migration

  1. Update auth_state table schema

    # 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

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

    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

    # 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

    ## [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:

    -- 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:
    -- 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

Supporting Specifications

Internal Documentation

  • ADR-005: IndieLogin Authentication Integration (conceptual flow)
  • ADR-016: IndieAuth Client Discovery Mechanism (superseded)
  • ADR-017: OAuth Client ID Metadata Document (superseded)
  • 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

# Check database for code_verifier storage
sqlite3 starpunk.db "SELECT state, code_verifier, expires_at FROM auth_state;"

# Watch logs during authentication
tail -f starpunk.log | grep "Auth:"

# Test PKCE generation
python3 -c "
from starpunk.auth import _generate_pkce_verifier, _generate_pkce_challenge
v = _generate_pkce_verifier()
c = _generate_pkce_challenge(v)
print(f'Verifier: {v}')
print(f'Challenge: {c}')
"

Testing the Implementation

# In Python REPL or test file
import requests

# Step 1: Check authorization URL
url = "http://localhost:5000/admin/login"
# Follow redirect, capture URL
# Should contain: code_challenge, code_challenge_method=S256

# Step 2: Check database
import sqlite3
conn = sqlite3.connect('starpunk.db')
cursor = conn.execute("SELECT * FROM auth_state ORDER BY created_at DESC LIMIT 1")
row = cursor.fetchone()
print(row)  # Should show state and code_verifier

# Step 3: Mock callback
# GET /auth/callback?code=XXX&state=YYY&iss=https://indielogin.com/

# Step 4: Check logs
# Should show POST to /token with code_verifier

Decided: 2025-11-19 Author: StarPunk Architect Agent Supersedes: ADR-016, ADR-017 Corrects: ADR-005 (implementation) Status: Proposed (awaiting user approval before implementation)