# 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](#authentication-flow)
2. [PKCE Implementation](#pkce-implementation)
3. [Database Schema Changes](#database-schema-changes)
4. [Code Changes](#code-changes)
5. [Code Removal](#code-removal)
6. [Testing Strategy](#testing-strategy)
7. [Error Handling](#error-handling)
8. [Security Considerations](#security-considerations)
9. [Implementation Guide](#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**:
```http
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)**:
```json
{
  "me": "https://user-site.com/"
}
```

**Error Response (400 Bad Request)**:
```json
{
  "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**:
```python
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**:
```python
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**:
```python
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

```sql
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**:

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

### Updated Schema (For Reference)

```sql
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**:
```python
import base64  # For PKCE challenge encoding
```

#### Change 2: Add PKCE Helper Functions

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

**Add**:
```python
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**:
```python
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**:
```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 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: `bool` → `Optional[str]`
- SELECT `code_verifier` column
- Return `code_verifier` or `None`

#### Change 4: Update `initiate_login()` Function

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

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

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

### 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**:
```python
code = request.args.get("code")
state = request.args.get("state")
```

**Change To**:
```python
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**:
```python
session_token = handle_callback(code, state)
```

**Change To**:
```python
session_token = handle_callback(code, state, iss)  # Pass issuer
```

**Update docstring** to document the `iss` parameter:
```python
"""
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**:
```python
@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`

#### Removal 2: IndieAuth Metadata Link

**DELETE**: Line ~11

```html
<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

```html
<!-- IndieAuth client discovery (h-app microformats) -->
<div class="h-app">
  <a href="{{ config.SITE_URL }}" class="u-url p-name">{{ config.get('SITE_NAME', 'StarPunk') }}</a>
</div>
```

**Reason**: Not required by IndieLogin.com API

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

```python
"""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`

```python
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**:
```python
if not is_valid_url(me_url):
    flash("Invalid URL format", "error")
    return redirect(url_for("auth.login_form"))
```

**Database error storing state**:
```python
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**:
```python
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**:
```python
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**:
```python
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**:
```python
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**:
```python
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**:
```python
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**:
```python
me = data.get("me")
if not me:
    flash("Authentication failed: No identity returned", "error")
    return redirect(url_for("auth.login_form"))
```

**Unauthorized user**:
```python
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**:
```python
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**:
```bash
# 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):
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
# 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**:
```bash
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**:
   ```bash
   git revert <commit-hash>
   git push origin main
   ```

2. **Revert Database** (optional):
   ```bash
   sqlite3 starpunk.db "ALTER TABLE auth_state DROP COLUMN code_verifier;"
   ```

3. **Emergency Bypass** (temporary):
   ```bash
   # Enable dev mode to bypass auth
   export DEV_MODE=True
   export DEV_ADMIN_ME=https://your-site.com
   ```

4. **Restore Backup**:
   ```bash
   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
