Files
StarPunk/docs/designs/indieauth-pkce-authentication.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

39 KiB

IndieAuth PKCE Authentication - Technical Design

Status: Ready for Implementation Related ADR: ADR-019 Last Updated: 2025-11-19

Overview

This document provides complete technical specifications for implementing IndieAuth authentication using IndieLogin.com's API with PKCE (Proof Key for Code Exchange). This design corrects the broken authentication implementation by following the official IndieLogin.com API requirements exactly.

Table of Contents

  1. Authentication Flow
  2. PKCE Implementation
  3. Database Schema Changes
  4. Code Changes
  5. Code Removal
  6. Testing Strategy
  7. Error Handling
  8. Security Considerations
  9. Implementation Guide

Authentication Flow

Complete Flow Diagram

┌─────────────┐
│ User clicks │
│   "Login"   │
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────┐
│ StarPunk: initiate_login()          │
│ 1. Validate me_url                  │
│ 2. Generate state token (CSRF)      │
│ 3. Generate code_verifier (random)  │
│ 4. Generate code_challenge (SHA256) │
│ 5. Store state + verifier in DB     │
│ 6. Redirect to IndieLogin.com       │
└──────┬──────────────────────────────┘
       │
       │ GET /authorize with:
       │ - me, 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 (GitHub etc)│
│ 4. User authenticates via provider  │
│ 5. Provider verifies identity       │
│ 6. Stores code_challenge            │
└──────┬──────────────────────────────┘
       │
       │ Redirect to redirect_uri with:
       │ - code, state, iss
       │
       ▼
┌─────────────────────────────────────┐
│ StarPunk: handle_callback()         │
│ 1. Validate state matches DB        │
│ 2. Validate iss = indielogin.com    │
│ 3. Retrieve code_verifier from DB   │
│ 4. POST to /token with code+verifier│
│ 5. Receive {"me": "user-url"}       │
│ 6. Verify me == ADMIN_ME            │
│ 7. Create session in database       │
│ 8. Set session cookie (HttpOnly)    │
│ 9. Redirect to /admin               │
└──────┬──────────────────────────────┘
       │
       ▼
┌─────────────┐
│ Logged In   │
│ (Admin)     │
└─────────────┘

Step 1: Authorization Request

Endpoint: https://indielogin.com/authorize

Method: GET (via redirect)

Required Parameters:

me              User's IndieWeb identity URL (e.g., https://user-site.com)
client_id       Application URL (e.g., https://starpunk.example.com)
redirect_uri    Callback URL (e.g., https://starpunk.example.com/auth/callback)
state           Random CSRF token (43+ characters, URL-safe)
code_challenge  Base64-URL encoded SHA256 hash of code_verifier
code_challenge_method  Must be "S256"

Optional Parameters:

prompt=login    Force fresh authentication (don't use existing session)

Example URL:

https://indielogin.com/authorize?
  me=https://user-site.com&
  client_id=https://starpunk.example.com&
  redirect_uri=https://starpunk.example.com/auth/callback&
  state=abc123xyz789random43characters&
  code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
  code_challenge_method=S256

Step 2: User Authentication (IndieLogin.com)

No implementation required - fully handled by IndieLogin.com:

  1. User enters their website URL (or uses pre-filled from me parameter)
  2. IndieLogin.com scans user's website for rel="me" links
  3. Shows available authentication providers (GitHub, Twitter, GitLab, Codeberg, email)
  4. User authenticates via chosen provider
  5. Provider verifies user owns the identity URL
  6. IndieLogin.com generates authorization code
  7. Stores code_challenge associated with authorization code

Step 3: Authorization Callback

Redirect back to our application with these parameters:

code   Authorization code (JWT format, single-use, short-lived)
state  Our original state value (must match stored value)
iss    Issuer identifier (should be "https://indielogin.com/")

Example Callback URL:

https://starpunk.example.com/auth/callback?
  code=eyJ0eXAiOiJKV1QiLCJhbGc...&
  state=abc123xyz789random43characters&
  iss=https://indielogin.com/

Our Validation:

  1. Check state exists and matches database record
  2. Check state not expired (5 minute max age)
  3. Check iss equals https://indielogin.com/
  4. Retrieve code_verifier associated with state
  5. Delete state from database (single-use)

Step 4: Token Exchange

Endpoint: https://indielogin.com/token

Method: POST

Content-Type: application/x-www-form-urlencoded

Required Parameters:

code           Authorization code from callback
client_id      Application URL (same as authorization request)
redirect_uri   Callback URL (same as authorization request)
code_verifier  Original PKCE verifier (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.example.com&
redirect_uri=https://starpunk.example.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"
}

Other Error Codes:

  • invalid_grant - Code expired or already used
  • invalid_client - client_id mismatch
  • invalid_request - Missing required parameter
  • unauthorized_client - code_verifier doesn't match code_challenge

PKCE Implementation

Code Verifier Generation

Requirements (RFC 7636):

  • Random URL-safe string
  • Length: 43-128 characters
  • Characters: [A-Z], [a-z], [0-9], -, ., _, ~

Implementation:

import secrets

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

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

    Returns:
        URL-safe base64-encoded random string (43 characters)
    """
    # secrets.token_urlsafe(32) generates 32 random bytes
    # Base64-URL encoding produces 43 characters
    verifier = secrets.token_urlsafe(32)
    return verifier

Example Output:

dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Code Challenge Generation

Requirements (RFC 7636, S256 method):

  1. SHA256 hash the code_verifier
  2. Base64-URL encode the hash
  3. Remove padding (=)

Implementation:

import hashlib
import base64

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
    per RFC 7636 S256 method.

    Args:
        verifier: The code_verifier string from _generate_pkce_verifier()

    Returns:
        Base64-URL encoded SHA256 hash (43 characters)
    """
    # SHA256 hash the verifier (returns 32 bytes)
    digest = hashlib.sha256(verifier.encode('utf-8')).digest()

    # Base64-URL encode (produces 44 characters with padding)
    challenge = base64.urlsafe_b64encode(digest).decode('utf-8')

    # Remove padding (produces 43 characters)
    return challenge.rstrip('=')

Example:

verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge = _generate_pkce_challenge(verifier)
# Result: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

PKCE Storage and Lifecycle

Storage: Database table auth_state with code_verifier column

Lifecycle:

  1. Generate: Create verifier during initiate_login()
  2. Store: Save with state token (5 minute TTL)
  3. Retrieve: Fetch using state during handle_callback()
  4. Send: Include in token exchange POST
  5. Delete: Remove from database after token exchange (single-use)

Security Properties:

  • Verifier never sent in URL (only in POST body)
  • Challenge sent in URL (safe - can't reverse SHA256)
  • Verifier-challenge pair single-use
  • Short-lived (5 minutes max)

Database Schema Changes

Current Schema

CREATE TABLE auth_state (
    state TEXT PRIMARY KEY,
    expires_at TIMESTAMP NOT NULL,
    redirect_uri TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Required Change

Add code_verifier column:

ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';

Updated Schema (For Reference)

CREATE TABLE auth_state (
    state TEXT PRIMARY KEY,
    code_verifier TEXT NOT NULL,  -- NEW COLUMN
    expires_at TIMESTAMP NOT NULL,
    redirect_uri TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Column Details:

  • code_verifier: Stores PKCE verifier (43-character string)
  • NOT NULL: Must always be present
  • DEFAULT '': Allows migration of existing rows

Index: Primary key on state is sufficient (state lookup is only access pattern)

Code Changes

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

Change 1: Add Import

Location: Top of file, with other imports

Add:

import base64  # For PKCE challenge encoding

Change 2: Add PKCE Helper Functions

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

Add:

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

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

    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
    per RFC 7636 S256 method.

    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 (no padding)
    challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
    return challenge

Change 3: Update _verify_state_token() Function

Location: Line ~194

Current Function:

def _verify_state_token(state: str) -> bool:
    """Verify and consume CSRF state token"""
    db = get_db(current_app)

    result = db.execute(
        """
        SELECT 1 FROM auth_state
        WHERE state = ? AND expires_at > datetime('now')
        """,
        (state,),
    ).fetchone()

    if not result:
        return False

    # Delete state (single-use)
    db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
    db.commit()

    return True

Replace 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 if invalid or expired
    """
    db = get_db(current_app)

    # Check if state exists and not expired, retrieve code_verifier
    result = db.execute(
        """
        SELECT code_verifier FROM auth_state
        WHERE state = ? AND expires_at > datetime('now')
        """,
        (state,),
    ).fetchone()

    if not result:
        return None

    code_verifier = result['code_verifier']

    # Delete state (single-use)
    db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
    db.commit()

    return code_verifier

Key Changes:

  • Return type: boolOptional[str]
  • SELECT code_verifier column
  • Return code_verifier or None

Change 4: Update initiate_login() Function

Location: Line ~249

Replace Lines 268-310 with:

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

    Args:
        me_url: User's IndieWeb identity URL

    Returns:
        Redirect URL to IndieLogin.com

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

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

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

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

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

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

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

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

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

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

    return auth_url

Key Changes:

  • Generate code_verifier and code_challenge
  • Store code_verifier in database with state
  • Add code_challenge and code_challenge_method to params
  • Remove response_type parameter (not needed)
  • Change endpoint from /auth to /authorize
  • Enhanced logging for PKCE parameters

Change 5: Update handle_callback() Function

Location: Line ~313

Replace Lines 313-404 with:

def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optional[str]:
    """
    Handle IndieLogin callback with PKCE verification.

    Args:
        code: Authorization code from IndieLogin
        state: CSRF state token
        iss: Issuer identifier (should be https://indielogin.com/)

    Returns:
        Session token if successful, None otherwise

    Raises:
        InvalidStateError: State token validation failed
        UnauthorizedError: User not authorized as admin
        IndieLoginError: Code exchange failed
    """
    current_app.logger.debug(f"Auth: Verifying state token: {_redact_token(state, 8)}")

    # Verify state token and retrieve code_verifier (CSRF protection)
    code_verifier = _verify_state_token(state)
    if not code_verifier:
        current_app.logger.warning(
            "Auth: Invalid state token received (possible CSRF or expired token)"
        )
        raise InvalidStateError("Invalid or expired state token")

    current_app.logger.debug("Auth: State token valid, code_verifier retrieved")

    # Verify issuer (security check)
    expected_iss = f"{current_app.config['INDIELOGIN_URL']}/"
    if iss and iss != expected_iss:
        current_app.logger.warning(
            f"Auth: Invalid issuer received: {iss} (expected {expected_iss})"
        )
        raise IndieLoginError(f"Invalid issuer: {iss}")

    current_app.logger.debug(f"Auth: Issuer verified: {iss}")

    # Prepare token exchange request with PKCE verifier
    token_exchange_data = {
        "code": code,
        "client_id": current_app.config["SITE_URL"],
        "redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback",
        "code_verifier": code_verifier,  # PKCE verification
    }

    # Log the request (code_verifier will be redacted)
    _log_http_request(
        method="POST",
        url=f"{current_app.config['INDIELOGIN_URL']}/token",
        data=token_exchange_data,
    )

    # Exchange code for identity (CORRECT ENDPOINT: /token)
    try:
        response = httpx.post(
            f"{current_app.config['INDIELOGIN_URL']}/token",
            data=token_exchange_data,
            timeout=10.0,
        )

        # Log the response
        _log_http_response(
            status_code=response.status_code,
            headers=dict(response.headers),
            body=response.text,
        )

        response.raise_for_status()
    except httpx.RequestError as e:
        current_app.logger.error(f"Auth: IndieLogin request failed: {e}")
        raise IndieLoginError(f"Failed to verify code: {e}")
    except httpx.HTTPStatusError as e:
        current_app.logger.error(
            f"Auth: IndieLogin returned error: {e.response.status_code} - {e.response.text}"
        )
        raise IndieLoginError(
            f"IndieLogin returned error: {e.response.status_code}"
        )

    # Parse response
    try:
        data = response.json()
    except Exception as e:
        current_app.logger.error(f"Auth: Failed to parse IndieLogin response: {e}")
        raise IndieLoginError("Invalid JSON response from IndieLogin")

    me = data.get("me")

    if not me:
        current_app.logger.error("Auth: No identity returned from IndieLogin")
        raise IndieLoginError("No identity returned from IndieLogin")

    current_app.logger.debug(f"Auth: Received identity from IndieLogin: {me}")

    # Verify this is the admin user
    admin_me = current_app.config.get("ADMIN_ME")
    if not admin_me:
        current_app.logger.error("Auth: ADMIN_ME not configured")
        raise UnauthorizedError("Admin user not configured")

    current_app.logger.info(f"Auth: Verifying admin authorization for me={me}")

    if me != admin_me:
        current_app.logger.warning(
            f"Auth: Unauthorized login attempt: {me} (expected {admin_me})"
        )
        raise UnauthorizedError(f"User {me} is not authorized")

    current_app.logger.debug("Auth: Admin verification passed")

    # Create session
    session_token = create_session(me)

    return session_token

Key Changes:

  • Add iss parameter to function signature
  • _verify_state_token() returns code_verifier (not boolean)
  • Validate iss parameter matches expected value
  • Include code_verifier in token exchange data
  • Change endpoint from /auth to /token
  • Better error handling and JSON parsing
  • Enhanced logging

Change 6: Update _log_http_request() Function

Location: Line ~90

In the redaction section (around lines 105-110), add:

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

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

Change 7: Update callback() Route

Location: Line ~86, function callback()

In the function (around line 104), update parameter extraction:

Current:

code = request.args.get("code")
state = request.args.get("state")

Change To:

code = request.args.get("code")
state = request.args.get("state")
iss = request.args.get("iss")  # NEW: Extract issuer parameter

Then update the callback call (around line 113):

Current:

session_token = handle_callback(code, state)

Change To:

session_token = handle_callback(code, state, iss)  # Pass issuer

Update docstring to document the iss parameter:

"""
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 Removal

File: /home/phil/Projects/starpunk/starpunk/routes/public.py

Removal 1: OAuth Metadata Endpoint

DELETE: Lines 150-217 (entire oauth_client_metadata() function)

Function to Remove:

@public_bp.route("/.well-known/oauth-authorization-server")
def oauth_client_metadata():
    """
    OAuth Client ID Metadata Document endpoint.
    [... entire function ...]
    """

Reason: Not required by IndieLogin.com API

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

DELETE: Line ~11

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

Reason: Links to removed OAuth metadata endpoint

Removal 3: h-app Microformats

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

Reason: Not required by IndieLogin.com API

Summary of Deletions

  • OAuth metadata endpoint function: ~68 lines
  • h-app microformats markup: ~4 lines
  • indieauth-metadata link: ~1 line
  • Total removed: ~73 lines

Testing Strategy

Unit Tests

File: tests/test_auth_pkce.py (new file)

"""Tests for PKCE implementation"""

import pytest
from starpunk.auth import _generate_pkce_verifier, _generate_pkce_challenge


def test_generate_pkce_verifier():
    """Test PKCE verifier generation"""
    verifier = _generate_pkce_verifier()

    # Length should be 43 characters
    assert len(verifier) == 43

    # Should only contain URL-safe characters
    assert verifier.replace('-', '').replace('_', '').isalnum()


def test_generate_pkce_verifier_unique():
    """Test that verifiers are unique"""
    verifier1 = _generate_pkce_verifier()
    verifier2 = _generate_pkce_verifier()

    assert verifier1 != verifier2


def test_generate_pkce_challenge():
    """Test PKCE challenge generation with known values"""
    # Example from RFC 7636
    verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    challenge = _generate_pkce_challenge(verifier)

    # Expected challenge for this verifier
    expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
    assert challenge == expected


def test_pkce_challenge_deterministic():
    """Test that 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():
    """Test that different verifiers produce different challenges"""
    verifier1 = _generate_pkce_verifier()
    verifier2 = _generate_pkce_verifier()

    challenge1 = _generate_pkce_challenge(verifier1)
    challenge2 = _generate_pkce_challenge(verifier2)

    assert challenge1 != challenge2


def test_pkce_challenge_length():
    """Test challenge is correct length"""
    verifier = _generate_pkce_verifier()
    challenge = _generate_pkce_challenge(verifier)

    # SHA256 hash -> 32 bytes -> 43 characters base64url (no padding)
    assert len(challenge) == 43

Integration Tests

Update: tests/test_auth.py

def test_initiate_login_with_pkce(app, client):
    """Test that login initiation includes PKCE parameters"""
    with app.app_context():
        me_url = "https://user.example.com"
        auth_url = initiate_login(me_url)

        # Parse URL
        from urllib.parse import urlparse, parse_qs
        parsed = urlparse(auth_url)
        params = parse_qs(parsed.query)

        # Check PKCE parameters present
        assert 'code_challenge' in params
        assert 'code_challenge_method' in params
        assert params['code_challenge_method'][0] == 'S256'

        # Check code_challenge is valid length
        assert len(params['code_challenge'][0]) == 43

        # Check correct endpoint
        assert parsed.path == '/authorize'


def test_state_token_returns_verifier(app):
    """Test that verifying state returns code_verifier"""
    with app.app_context():
        db = get_db(app)

        # Create state with verifier
        state = "test_state_123"
        verifier = "test_verifier_abc"
        expires = datetime.utcnow() + timedelta(minutes=5)

        db.execute(
            "INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
            "VALUES (?, ?, ?, ?)",
            (state, verifier, expires, "http://test.com/callback")
        )
        db.commit()

        # Verify state returns verifier
        returned_verifier = _verify_state_token(state)
        assert returned_verifier == verifier

        # State should be deleted
        result = db.execute(
            "SELECT * FROM auth_state WHERE state = ?", (state,)
        ).fetchone()
        assert result is None


def test_handle_callback_with_iss(app, mocker):
    """Test callback handling validates issuer"""
    with app.app_context():
        # Setup
        state = "test_state"
        verifier = "test_verifier"
        code = "test_code"
        iss = "https://indielogin.com/"

        # Store state with verifier
        db = get_db(app)
        expires = datetime.utcnow() + timedelta(minutes=5)
        db.execute(
            "INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
            "VALUES (?, ?, ?, ?)",
            (state, verifier, expires, "http://test/callback")
        )
        db.commit()

        # Mock HTTP response
        mock_response = mocker.Mock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"me": app.config['ADMIN_ME']}
        mocker.patch('httpx.post', return_value=mock_response)

        # Call with valid issuer
        session_token = handle_callback(code, state, iss)
        assert session_token is not None

        # Verify POST included code_verifier
        httpx.post.assert_called_once()
        call_args = httpx.post.call_args
        assert call_args[1]['data']['code_verifier'] == verifier


def test_handle_callback_invalid_issuer(app, mocker):
    """Test callback rejects invalid issuer"""
    with app.app_context():
        state = "test_state"
        verifier = "test_verifier"
        code = "test_code"
        iss = "https://evil.com/"  # Wrong issuer

        # Store state with verifier
        db = get_db(app)
        expires = datetime.utcnow() + timedelta(minutes=5)
        db.execute(
            "INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri) "
            "VALUES (?, ?, ?, ?)",
            (state, verifier, expires, "http://test/callback")
        )
        db.commit()

        # Should raise IndieLoginError
        with pytest.raises(IndieLoginError):
            handle_callback(code, state, iss)

Manual Testing Checklist

Preparation:

  • Database migration completed
  • Code changes deployed
  • Server restarted
  • LOG_LEVEL=DEBUG set for detailed logs

Happy Path:

  1. Navigate to /admin/login
  2. Enter your IndieWeb identity URL
  3. Click "Sign In"
  4. Verify redirect to https://indielogin.com/authorize
  5. Verify URL contains code_challenge parameter
  6. Verify URL contains code_challenge_method=S256
  7. Complete authentication on IndieLogin.com
  8. Verify redirect back to /auth/callback
  9. Verify redirect to /admin dashboard
  10. Verify session cookie set
  11. Verify access to admin pages works

Error Cases:

  • Invalid state token → Error message
  • Expired state token → Error message
  • Wrong issuer → Error message
  • Invalid authorization code → Error from IndieLogin
  • Wrong me URL → Unauthorized error
  • Network error → Graceful error message

Security:

  • State tokens single-use (can't replay)
  • State tokens expire after 5 minutes
  • code_verifier stored in database
  • code_verifier deleted after use
  • Logs show redacted tokens
  • Session cookies HttpOnly
  • Session cookies Secure (in production)

Error Handling

Authorization Request Errors

Invalid me_url:

if not is_valid_url(me_url):
    flash("Invalid URL format", "error")
    return redirect(url_for("auth.login_form"))

Database error storing state:

try:
    db.execute(...)
    db.commit()
except Exception as e:
    current_app.logger.error(f"Failed to store auth state: {e}")
    flash("Authentication initialization failed", "error")
    return redirect(url_for("auth.login_form"))

Callback Errors

Missing parameters:

code = request.args.get("code")
state = request.args.get("state")

if not code or not state:
    flash("Missing authentication parameters", "error")
    return redirect(url_for("auth.login_form"))

Invalid state:

code_verifier = _verify_state_token(state)
if not code_verifier:
    flash("Invalid or expired authentication request", "error")
    return redirect(url_for("auth.login_form"))

Invalid issuer:

expected_iss = f"{current_app.config['INDIELOGIN_URL']}/"
if iss and iss != expected_iss:
    flash("Authentication failed: Invalid issuer", "error")
    return redirect(url_for("auth.login_form"))

Token Exchange Errors

Network errors:

try:
    response = httpx.post(...)
except httpx.RequestError as e:
    current_app.logger.error(f"IndieLogin request failed: {e}")
    flash("Authentication service unavailable", "error")
    return redirect(url_for("auth.login_form"))

HTTP errors:

except httpx.HTTPStatusError as e:
    current_app.logger.error(f"IndieLogin error: {e.response.status_code}")
    flash("Authentication failed", "error")
    return redirect(url_for("auth.login_form"))

JSON parse errors:

try:
    data = response.json()
except Exception as e:
    current_app.logger.error(f"Failed to parse response: {e}")
    flash("Authentication failed: Invalid response", "error")
    return redirect(url_for("auth.login_form"))

Missing identity:

me = data.get("me")
if not me:
    flash("Authentication failed: No identity returned", "error")
    return redirect(url_for("auth.login_form"))

Unauthorized user:

if me != admin_me:
    current_app.logger.warning(f"Unauthorized login: {me}")
    flash(f"User {me} is not authorized", "error")
    return redirect(url_for("auth.login_form"))

Security Considerations

PKCE Security Properties

Prevents Authorization Code Interception:

  • Attacker intercepts authorization code from URL
  • Attacker cannot exchange code without code_verifier
  • code_challenge in URL cannot be reversed (SHA256 is one-way)
  • Server validates code_verifier matches original code_challenge

Why PKCE is Critical:

  • Mobile apps (can't secure client secret)
  • Public clients (JavaScript apps, CLI tools)
  • Protection against compromised redirect URIs
  • Defense-in-depth even with HTTPS

State Token Security

CSRF Protection:

  • Random 43+ character token
  • Stored server-side with expiration
  • Validated on callback
  • Single-use (deleted after verification)
  • Short TTL (5 minutes)

Issuer Validation

Prevents Token Substitution:

  • Validates iss parameter matches expected value
  • Protects against malicious authorization servers
  • Required by OAuth 2.0 best practices

Sensitive Data Protection

In Logs:

  • Tokens redacted (show first 6-8 and last 4 characters)
  • code_verifier never logged in full
  • HTTP request/response bodies sanitized

In Database:

  • code_verifier stored plaintext (needed for token exchange)
  • Deleted immediately after use
  • Short TTL reduces exposure window

In Transit:

  • code_challenge sent in URL (safe - can't reverse)
  • code_verifier sent in POST body (not in URL)
  • HTTPS required in production

Session Security

Cookie Properties:

response.set_cookie(
    "starpunk_session",
    session_token,
    max_age=30 * 24 * 60 * 60,  # 30 days
    httponly=True,               # No JavaScript access
    secure=True,                 # HTTPS only (production)
    samesite="Lax"               # CSRF protection
)

Session Token:

  • Cryptographically random (secrets module)
  • Hashed with SHA256 before storage
  • 43+ characters
  • Long-lived but can be revoked

Implementation Guide

Phase 1: Database Migration

Development:

# Option 1: Alter existing table
sqlite3 starpunk.db "ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';"

# Option 2: Drop and recreate (if no production data)
sqlite3 starpunk.db < schema.sql

Production (if needed):

# Backup first
cp starpunk.db starpunk.db.backup

# Add column
sqlite3 starpunk.db "ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';"

# Verify
sqlite3 starpunk.db "PRAGMA table_info(auth_state);"

Note: Existing state tokens will be invalid after deployment (they lack code_verifier). This is acceptable because:

  • State tokens expire in 5 minutes
  • Users mid-authentication will restart login
  • Existing sessions remain valid

Phase 2: Code Changes

Step 1: Add PKCE Functions

  1. Open /home/phil/Projects/starpunk/starpunk/auth.py
  2. Add import base64 at top
  3. Add _generate_pkce_verifier() function after imports
  4. Add _generate_pkce_challenge() function after verifier function
  5. Save file

Step 2: Update Helper Functions

  1. Find _verify_state_token() function (~line 194)
  2. Replace entire function with new version
  3. Find _log_http_request() function (~line 90)
  4. Add code_verifier to redaction list
  5. Save file

Step 3: Update Core Functions

  1. Find initiate_login() function (~line 249)
  2. Replace lines 268-310 with new implementation
  3. Find handle_callback() function (~line 313)
  4. Replace lines 313-404 with new implementation
  5. Save file

Step 4: Update Routes

  1. Open /home/phil/Projects/starpunk/starpunk/routes/auth.py
  2. Find callback() function (~line 86)
  3. Add iss = request.args.get("iss")
  4. Pass iss to handle_callback(code, state, iss)
  5. Update docstring
  6. Save file

Phase 3: Code Removal

Step 1: Remove OAuth Metadata

  1. Open /home/phil/Projects/starpunk/starpunk/routes/public.py
  2. Find oauth_client_metadata() function (~line 150)
  3. Delete entire function (lines 150-217)
  4. Save file

Step 2: Remove Template Markup

  1. Open /home/phil/Projects/starpunk/templates/base.html
  2. Find and delete indieauth-metadata link (~line 11)
  3. Find and delete h-app div (~lines 48-51)
  4. Save file

Phase 4: Testing

Unit Tests:

# Create test file
touch tests/test_auth_pkce.py

# Add PKCE tests (see Testing Strategy section)

# Run tests
uv run pytest tests/test_auth_pkce.py -v

Integration Tests:

# Update existing tests
# Add PKCE-specific test cases (see Testing Strategy section)

# Run all auth tests
uv run pytest tests/test_auth.py -v

Manual Testing:

# Start server in debug mode
export LOG_LEVEL=DEBUG
uv run python -m starpunk

# Follow manual testing checklist (see Testing Strategy section)

# Monitor logs
tail -f starpunk.log | grep "Auth:"

Phase 5: Verification

Check PKCE in Logs:

[2025-11-19 10:30:15] DEBUG - Auth: Generated PKCE pair:
  verifier: abc123...********...xyz9
  challenge: def456...********...uvw8

Check Database:

sqlite3 starpunk.db "SELECT state, code_verifier, expires_at FROM auth_state ORDER BY created_at DESC LIMIT 1;"

Check Authorization URL:

https://indielogin.com/authorize?
  me=https://user.com&
  client_id=https://starpunk.example.com&
  redirect_uri=https://starpunk.example.com/auth/callback&
  state=abc123xyz...&
  code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
  code_challenge_method=S256

Phase 6: Deployment

Pre-Deployment:

  • All tests passing
  • Manual testing complete
  • Database migration script ready
  • Rollback plan documented

Deployment Steps:

  1. Backup database
  2. Deploy code changes
  3. Run database migration
  4. Restart application
  5. Test authentication flow
  6. Monitor logs for errors
  7. Verify no regression in existing sessions

Post-Deployment:

  • Monitor error rates
  • Check authentication success rate
  • Verify PKCE parameters in logs
  • Test from different browsers/devices

Rollback Plan

If Implementation Fails:

  1. Revert Code:

    git revert <commit-hash>
    git push origin main
    
  2. Revert Database (optional):

    sqlite3 starpunk.db "ALTER TABLE auth_state DROP COLUMN code_verifier;"
    
  3. Emergency Bypass (temporary):

    # Enable dev mode to bypass auth
    export DEV_MODE=True
    export DEV_ADMIN_ME=https://your-site.com
    
  4. Restore Backup:

    cp starpunk.db.backup starpunk.db
    systemctl restart starpunk
    

Success Criteria

Implementation successful when:

  • User can complete login flow
  • PKCE parameters in authorization URL
  • code_verifier stored with state
  • Callback validates state and issuer
  • Token exchange includes code_verifier
  • Token exchange returns identity
  • Admin verification passes
  • Session created successfully
  • All tests pass
  • No errors in production logs

Document Version: 1.0 Last Updated: 2025-11-19 Status: Ready for Implementation Related ADR: ADR-019