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

## Status

Proposed

## Context

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

### Current Failure

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

### What We Misunderstood

We conflated:
1. **Generic IndieAuth specification** (full OAuth 2.0 with client discovery)
2. **IndieLogin.com API** (simplified authentication-only service with specific requirements)

IndieLogin.com is a **simplified authentication service**, not a full OAuth 2.0 authorization server. It has specific API requirements that differ from the generic IndieAuth spec.

## Section 1: What We Did Wrong

### Critical Errors in Current Implementation

#### 1. Missing PKCE Implementation (CRITICAL)

**Current Code** (`starpunk/auth.py` line 287-293):
```python
params = {
    "me": me_url,
    "client_id": current_app.config["SITE_URL"],
    "redirect_uri": redirect_uri,
    "state": state,
    "response_type": "code",  # NOT REQUIRED by IndieLogin.com
}
```

**What's Wrong**: IndieLogin.com **requires** PKCE parameters:
- `code_challenge`: Base64-URL encoded SHA256 hash of random string
- `code_challenge_method`: Must be `S256`
- `code_verifier`: Original unencoded string (sent later during token exchange)

**We're not generating, storing, or sending any of these.**

#### 2. Wrong Authorization Endpoint

**Current Code** (`starpunk/auth.py` line 305):
```python
auth_url = f"{current_app.config['INDIELOGIN_URL']}/auth?{urlencode(params)}"
```

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

**Correct**: `https://indielogin.com/authorize`

#### 3. Wrong Token Exchange Endpoint

**Current Code** (`starpunk/auth.py` line 354-356):
```python
response = httpx.post(
    f"{current_app.config['INDIELOGIN_URL']}/auth",  # WRONG ENDPOINT
    data=token_exchange_data,
    timeout=10.0,
)
```

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

**Correct**: `https://indielogin.com/token`

#### 4. Missing code_verifier in Token Exchange

**Current Code** (`starpunk/auth.py` line 339-343):
```python
token_exchange_data = {
    "code": code,
    "client_id": current_app.config["SITE_URL"],
    "redirect_uri": f"{current_app.config['SITE_URL']}/auth/callback",
    # MISSING: code_verifier
}
```

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

#### 5. Unnecessary response_type Parameter

**Current Code**:
```python
"response_type": "code",
```

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

#### 6. Missing iss Validation

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

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

### Unnecessary Features We Added

#### 1. OAuth Metadata Endpoint (NOT NEEDED)

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

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

**Why It's Not Needed**: IndieLogin.com API documentation makes NO mention of:
- Client registration
- Client metadata discovery
- `/.well-known/oauth-authorization-server` endpoint
- JSON metadata documents

IndieLogin.com accepts ANY valid `client_id` URL without pre-registration.

#### 2. h-app Microformats (NOT NEEDED)

**File**: `templates/base.html` lines 48-51

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

**Why We Added It**: ADR-016 proposed this for client discovery

**Why It's Not Needed**: IndieLogin.com API docs don't require or mention h-app microformats. The service works with the `client_id` URL directly.

#### 3. indieauth-metadata Link (NOT NEEDED)

**File**: `templates/base.html` line 11

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

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

### What Misled Us

1. **Reading Generic IndieAuth Spec**: We read the full IndieAuth specification which describes client discovery, authorization servers, etc. IndieLogin.com is a simplified service that doesn't require all of this.

2. **Assuming OAuth 2.0 Full Compliance**: We assumed IndieLogin.com needed OAuth 2.0 client registration. It doesn't - it's authentication-only, not authorization.

3. **Not Reading IndieLogin.com API Docs First**: We should have started with https://indielogin.com/api instead of the generic spec.

4. **Confusing Authentication vs Authorization**: IndieLogin.com provides **authentication** (who are you?) not **authorization** (what can you access?). No scopes, no access tokens for API access - just identity verification.

## Section 2: The Correct Approach

### Authentication vs Authorization

**IndieLogin.com provides AUTHENTICATION ONLY**

We need: **Web Sign-In Flow** (heading "1. Create a Web Sign In Form" in API docs)

We do NOT need: Authorization flow with scopes (that's for Micropub later)

### The Correct 4-Step Flow

#### Step 1: Create Authorization Request with PKCE

**POST/GET to**: `https://indielogin.com/authorize`

**Required Parameters**:
- `client_id`: Our application URL (e.g., `https://starpunk.thesatelliteoflove.com`)
- `redirect_uri`: Our callback URL (must be on same domain as client_id)
- `state`: Random value for CSRF protection
- `code_challenge`: Base64-URL encoded SHA256 hash of code_verifier
- `code_challenge_method`: `S256`

**Optional Parameters**:
- `me`: Pre-fill user's URL (prompt if omitted)
- `prompt=login`: Force fresh authentication

**Example**:
```
https://indielogin.com/authorize?
  client_id=https://starpunk.thesatelliteoflove.com&
  redirect_uri=https://starpunk.thesatelliteoflove.com/auth/callback&
  state=abc123xyz789&
  code_challenge=K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U&
  code_challenge_method=S256&
  me=https://user-site.com
```

#### Step 2: User Authentication (Handled by IndieLogin.com)

IndieLogin.com:
1. Scans user's website for `rel="me"` links
2. Shows authentication options (GitHub, Twitter, GitLab, Codeberg, email)
3. User authenticates via chosen provider
4. IndieLogin.com verifies identity

**We don't implement anything for this step** - it's all handled by IndieLogin.com

#### Step 3: Handle Redirect Callback

**IndieLogin.com redirects to our redirect_uri with**:
- `code`: Authorization code (to exchange for identity)
- `state`: Our original state value (MUST VALIDATE)
- `iss`: Should equal `https://indielogin.com/` (SHOULD VALIDATE)

**Example**:
```
https://starpunk.thesatelliteoflove.com/auth/callback?
  code=eyJ0eXAiOiJKV1QiLCJhbGc...&
  state=abc123xyz789&
  iss=https://indielogin.com/
```

**Our Implementation Must**:
1. Validate `state` matches our stored value (CSRF protection)
2. Validate `iss` equals `https://indielogin.com/` (issuer verification)
3. Extract `code` for next step

#### Step 4: Exchange Code for Identity

**POST to**: `https://indielogin.com/token`

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

**Required Parameters**:
- `code`: The authorization code from step 3
- `client_id`: Our application URL (same as step 1)
- `redirect_uri`: Our callback URL (same as step 1)
- `code_verifier`: The original random string (before hashing)

**Example Request**:
```http
POST /token HTTP/1.1
Host: indielogin.com
Content-Type: application/x-www-form-urlencoded

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

**Success Response (200 OK)**:
```json
{
  "me": "https://user-site.com/"
}
```

**Error Response (400 Bad Request)**:
```json
{
  "error": "invalid_request",
  "error_description": "The code provided was not valid"
}
```

### PKCE Implementation Details

#### Generate code_verifier

```python
import secrets
import base64
import hashlib

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

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

#### Generate code_challenge from code_verifier

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

    Args:
        verifier: The code_verifier string

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

#### Example Usage

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

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

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

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

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

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

### Session Management for code_verifier

**Database Schema Update Required**:

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

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

**Storage Flow**:
1. Generate state + code_verifier together
2. Store BOTH in auth_state table
3. Set short expiry (5 minutes)
4. On callback: retrieve code_verifier using state
5. Delete state row after use (single-use)

### Complete Flow Diagram

```
┌─────────────┐
│ User clicks │
│   "Login"   │
└──────┬──────┘
       │
       ▼
┌─────────────────────────────────────┐
│ StarPunk: initiate_login()          │
│ 1. Generate state token             │
│ 2. Generate code_verifier (random)  │
│ 3. Generate code_challenge (SHA256) │
│ 4. Store state + verifier in DB     │
│ 5. Redirect to IndieLogin.com       │
└──────┬──────────────────────────────┘
       │
       │ Redirect to /authorize with:
       │ - client_id, redirect_uri, state
       │ - code_challenge, code_challenge_method
       │
       ▼
┌─────────────────────────────────────┐
│ IndieLogin.com                      │
│ 1. User enters their website URL    │
│ 2. Scans for rel="me" links         │
│ 3. Shows auth providers             │
│ 4. User authenticates               │
│ 5. Verifies identity                │
└──────┬──────────────────────────────┘
       │
       │ Redirect back with:
       │ - code, state, iss
       │
       ▼
┌─────────────────────────────────────┐
│ StarPunk: handle_callback()         │
│ 1. Validate state matches           │
│ 2. Validate iss = indielogin.com    │
│ 3. Retrieve code_verifier from DB   │
│ 4. POST to /token with verifier     │
│ 5. Receive {"me": "user-url"}       │
│ 6. Verify me == ADMIN_ME            │
│ 7. Create session                   │
│ 8. Set session cookie               │
│ 9. Redirect to /admin               │
└──────┬──────────────────────────────┘
       │
       ▼
┌─────────────┐
│ Logged In   │
└─────────────┘
```

### Error Handling

**During Authorization Request**:
- Validate me URL format
- Ensure HTTPS in production
- Handle DB errors storing state

**During Callback**:
- Missing code/state/iss → Error page "Authentication failed"
- Invalid state → Error "CSRF token invalid" (security error)
- iss mismatch → Error "Invalid issuer" (security error)
- State not found in DB → Error "State expired or invalid"
- Code verifier not found → Error "Authentication state lost"

**During Token Exchange**:
- Network errors → Retry once, then error "IndieLogin.com unavailable"
- 400 response → Error "Invalid authorization code"
- Missing "me" in response → Error "No identity returned"
- me != ADMIN_ME → Error "Unauthorized user"
- JSON parse error → Error "Invalid response from IndieLogin.com"

## Section 3: Code to Remove

### 1. Remove OAuth Metadata Endpoint

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

**Lines to DELETE**: 150-217 (entire `oauth_client_metadata()` function)

**Route to Remove**: `/.well-known/oauth-authorization-server`

**Reason**: Not required by IndieLogin.com API. Adds unnecessary complexity.

### 2. Remove h-app Microformats

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

**Lines to DELETE**: 48-51

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

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

### 3. Remove indieauth-metadata Link

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

**Line to DELETE**: 11

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

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

### Summary of Deletions

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

## Section 4: Code to Add/Modify

### 1. Add PKCE Functions

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

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

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

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

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


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

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

    Args:
        verifier: The code_verifier string from _generate_pkce_verifier()

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

**Additional Import Required**:
```python
import base64  # Add to existing imports at top
```

### 2. Update Database Schema

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

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

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

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

### 3. Update initiate_login() Function

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

**Function**: `initiate_login()` (starting line 249)

**REPLACE** lines 268-310 with:

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

    Args:
        me_url: User's IndieWeb identity URL

    Returns:
        Redirect URL to IndieLogin.com

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

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

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

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

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

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

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

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

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

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

    return auth_url
```

**Key Changes**:
1. Generate `code_verifier` and `code_challenge`
2. Store `code_verifier` in database with state
3. Send `code_challenge` and `code_challenge_method` in params
4. Remove `response_type` parameter (not needed)
5. Change endpoint from `/auth` to `/authorize`

### 4. Update _verify_state_token() Function

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

**Function**: `_verify_state_token()` (starting line 194)

**REPLACE** entire function with:

```python
def _verify_state_token(state: str) -> Optional[str]:
    """
    Verify and consume CSRF state token, returning code_verifier.

    Args:
        state: State token to verify

    Returns:
        code_verifier string if valid, None otherwise
    """
    db = get_db(current_app)

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

    if not result:
        return None

    code_verifier = result['code_verifier']

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

    return code_verifier
```

**Key Changes**:
1. Return `code_verifier` instead of boolean
2. SELECT code_verifier from database
3. Return None if invalid (instead of False)

### 5. Update handle_callback() Function

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

**Function**: `handle_callback()` (starting line 313)

**REPLACE** lines 313-404 with:

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

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

    Returns:
        Session token if successful, None otherwise

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

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

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

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

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

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

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

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

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

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

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

    me = data.get("me")

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

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

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

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

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

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

    # Create session
    session_token = create_session(me)

    return session_token
```

**Key Changes**:
1. Add `iss` parameter to function signature
2. Verify state returns `code_verifier` (not boolean)
3. Validate `iss` parameter if present
4. Include `code_verifier` in token exchange data
5. Change endpoint from `/auth` to `/token`
6. Add better error handling for JSON parsing
7. Update logging to show code_verifier (will be redacted)

### 6. Update Callback Route

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

**Function**: `callback()` (starting line 86)

**REPLACE** lines 104-113 with:

```python
def callback():
    """
    Handle IndieLogin callback.

    Processes the OAuth callback from IndieLogin.com, validates the
    authorization code, state token, and issuer, then creates an
    authenticated session using PKCE verification.

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

    Returns:
        Redirect to admin dashboard on success, login form on failure

    Sets:
        session cookie (HttpOnly, Secure, SameSite=Lax, 30 day expiry)
    """
    code = request.args.get("code")
    state = request.args.get("state")
    iss = request.args.get("iss")  # NEW: Get issuer parameter

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

    try:
        # Handle callback with PKCE verification
        session_token = handle_callback(code, state, iss)  # Pass iss parameter

        # ... rest of function unchanged ...
```

**Key Changes**:
1. Extract `iss` parameter from request
2. Pass `iss` to `handle_callback()`
3. Update docstring to document `iss` parameter

### 7. Update Logging Helper

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

**Function**: `_log_http_request()` (line 90)

**ADD** code_verifier to redaction list (line 105-110):

```python
# Redact sensitive data
safe_data = data.copy()
if "code" in safe_data:
    safe_data["code"] = _redact_token(safe_data["code"])
if "state" in safe_data:
    safe_data["state"] = _redact_token(safe_data["state"], 8)
if "code_verifier" in safe_data:  # NEW: Redact PKCE verifier
    safe_data["code_verifier"] = _redact_token(safe_data["code_verifier"])
```

## Section 5: Superseded Documentation

The following ADRs and decisions are **SUPERSEDED** by this ADR:

### Superseded ADRs

1. **ADR-016: IndieAuth Client Discovery Mechanism**
   - **File**: `/home/phil/Projects/starpunk/docs/decisions/ADR-016-indieauth-client-discovery.md`
   - **Status**: Change to "Superseded by ADR-019"
   - **Reason**: h-app microformats not required by IndieLogin.com API
   - **Action**: Update status field in document

2. **ADR-017: OAuth Client ID Metadata Document Implementation**
   - **File**: `/home/phil/Projects/starpunk/docs/decisions/ADR-017-oauth-client-metadata-document.md`
   - **Status**: Change to "Superseded by ADR-019"
   - **Reason**: OAuth metadata endpoint not required by IndieLogin.com API
   - **Action**: Update status field in document

### Partially Superseded ADRs

3. **ADR-005: IndieLogin Authentication Integration**
   - **File**: `/home/phil/Projects/starpunk/docs/decisions/ADR-005-indielogin-authentication.md`
   - **Status**: Remains "Accepted" but needs correction note
   - **Reason**: Flow was correct in concept but implementation details were wrong (missing PKCE, wrong endpoints)
   - **Action**: Add note at top: "NOTE: Implementation corrected in ADR-019 (PKCE, endpoints)"

### Update Instructions

Add to top of each superseded ADR:

**ADR-016 and ADR-017**:
```markdown
## Status

Superseded by ADR-019

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

**ADR-005** (add note below Status):
```markdown
## Status

Accepted

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

## Section 6: Implementation Plan

### Step-by-Step Implementation Guide

#### Phase 1: Database Migration

1. **Update auth_state table schema**
   ```bash
   # Create migration or update schema.sql
   ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
   ```

2. **Test migration**
   - Run migration on dev database
   - Verify column exists
   - Verify existing rows have empty string default

#### Phase 2: Add PKCE Functions

1. **Add imports to auth.py**
   - Add `import base64` to imports section

2. **Add PKCE helper functions**
   - Add `_generate_pkce_verifier()` function
   - Add `_generate_pkce_challenge()` function
   - Place after imports, before existing helper functions

3. **Test PKCE functions**
   ```python
   # Manual test in Python REPL
   verifier = _generate_pkce_verifier()
   assert len(verifier) == 43

   challenge = _generate_pkce_challenge(verifier)
   assert len(challenge) == 43
   assert challenge != verifier
   ```

#### Phase 3: Update Core Auth Functions

1. **Update `_verify_state_token()` function**
   - Change return type from bool to Optional[str]
   - SELECT and return code_verifier
   - Update all callers (only handle_callback uses it)

2. **Update `initiate_login()` function**
   - Generate code_verifier and code_challenge
   - Store code_verifier in database
   - Add PKCE params to authorization URL
   - Remove response_type param
   - Change endpoint to /authorize

3. **Update `handle_callback()` function**
   - Add iss parameter
   - Receive code_verifier from _verify_state_token
   - Validate iss parameter
   - Include code_verifier in token exchange
   - Change endpoint to /token
   - Improve error handling

4. **Update `_log_http_request()` function**
   - Add code_verifier to redaction list

#### Phase 4: Update Routes

1. **Update callback route**
   - Extract iss parameter from request
   - Pass iss to handle_callback()
   - Update docstring

#### Phase 5: Remove Unnecessary Code

1. **Remove from templates/base.html**
   - Delete indieauth-metadata link (line 11)
   - Delete h-app microformats div (lines 48-51)

2. **Remove from routes/public.py**
   - Delete oauth_client_metadata() function (lines 150-217)
   - Remove route registration

#### Phase 6: Testing

1. **Unit Tests** (create `tests/test_auth_pkce.py`)
   ```python
   def test_generate_pkce_verifier():
       verifier = _generate_pkce_verifier()
       assert len(verifier) == 43
       assert verifier.replace('-', '').replace('_', '').isalnum()

   def test_generate_pkce_challenge():
       verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
       challenge = _generate_pkce_challenge(verifier)
       # Expected value from PKCE spec example
       assert challenge == "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

   def test_pkce_challenge_is_deterministic():
       verifier = _generate_pkce_verifier()
       challenge1 = _generate_pkce_challenge(verifier)
       challenge2 = _generate_pkce_challenge(verifier)
       assert challenge1 == challenge2

   def test_different_verifiers_different_challenges():
       verifier1 = _generate_pkce_verifier()
       verifier2 = _generate_pkce_verifier()
       challenge1 = _generate_pkce_challenge(verifier1)
       challenge2 = _generate_pkce_challenge(verifier2)
       assert challenge1 != challenge2
   ```

2. **Integration Tests** (update existing tests)
   - Mock IndieLogin.com responses
   - Test full flow with PKCE
   - Test state + verifier storage/retrieval
   - Test verifier validation
   - Test iss validation

3. **Manual Testing**
   ```bash
   # 1. Start dev server
   uv run python -m starpunk

   # 2. Navigate to /admin/login
   # 3. Enter your website URL
   # 4. Complete IndieLogin.com authentication
   # 5. Verify successful redirect to /admin
   # 6. Check logs for PKCE parameters
   ```

#### Phase 7: Update Documentation

1. **Update superseded ADRs**
   - Add superseded notice to ADR-016
   - Add superseded notice to ADR-017
   - Add implementation note to ADR-005

2. **Update CHANGELOG.md**
   ```markdown
   ## [0.6.3] - 2025-11-19

   ### Fixed
   - IndieAuth authentication now works correctly with IndieLogin.com
   - Added required PKCE (Proof Key for Code Exchange) support
   - Corrected IndieLogin.com API endpoints (/authorize and /token)
   - Added issuer validation for security

   ### Removed
   - Unnecessary OAuth metadata endpoint (/.well-known/oauth-authorization-server)
   - Unnecessary h-app microformats markup
   - Unnecessary indieauth-metadata link

   ### Changed
   - auth_state database table now includes code_verifier column
   ```

3. **Increment version**
   - Update version to 0.6.3 (patch - critical bug fix)

### Testing Strategy

#### Manual Testing Checklist

- [ ] Login form displays at /admin/login
- [ ] Entering URL redirects to IndieLogin.com
- [ ] IndieLogin.com shows authentication options
- [ ] Completing auth redirects back to StarPunk
- [ ] Callback validates state correctly
- [ ] Callback validates iss correctly
- [ ] Token exchange succeeds with code_verifier
- [ ] Session created successfully
- [ ] Redirected to /admin dashboard
- [ ] Session persists across requests
- [ ] Logout destroys session

#### Error Case Testing

- [ ] Invalid state token → Error message
- [ ] Expired state token → Error message
- [ ] Wrong iss value → Error message
- [ ] Invalid code → Error from IndieLogin.com
- [ ] Wrong me URL → Unauthorized error
- [ ] Network error during token exchange → Graceful error

#### Security Testing

- [ ] State tokens are single-use
- [ ] State tokens expire after 5 minutes
- [ ] code_verifier stored securely
- [ ] code_verifier deleted after use
- [ ] Session cookies are HttpOnly
- [ ] Session cookies are Secure (in production)
- [ ] CSRF protection works

### Migration Notes

#### For Development Environments

1. Drop and recreate auth_state table (data not important in dev)
2. Update code
3. Test authentication flow

#### For Production Environments (if any exist)

1. **Database Migration**:
   ```sql
   -- Add column with default
   ALTER TABLE auth_state ADD COLUMN code_verifier TEXT NOT NULL DEFAULT '';
   ```

2. **Note**: Existing state tokens in database will be invalid after deploy
   - State tokens expire in 5 minutes anyway
   - Users mid-authentication will need to restart login
   - Existing sessions remain valid (no impact to logged-in users)

3. **Deployment Steps**:
   - Deploy code changes
   - Run database migration
   - Restart application
   - Test login flow
   - Monitor logs for errors

### Rollback Plan

If implementation fails:

1. **Code Rollback**: Revert to previous commit
2. **Database Rollback**:
   ```sql
   -- Remove column
   ALTER TABLE auth_state DROP COLUMN code_verifier;
   ```
3. **Quick Fix**: Re-enable DEV_MODE to bypass auth temporarily

### Success Criteria

Implementation is successful when:

1. ✅ User can initiate login at /admin/login
2. ✅ Redirect to IndieLogin.com /authorize includes PKCE parameters
3. ✅ code_verifier stored in database with state
4. ✅ Callback receives code, state, and iss
5. ✅ State validation returns code_verifier
6. ✅ Issuer validation passes
7. ✅ Token exchange to /token includes code_verifier
8. ✅ Token exchange returns {"me": "user-url"}
9. ✅ Admin user verification passes
10. ✅ Session created and cookie set
11. ✅ Redirect to /admin dashboard succeeds
12. ✅ All unit tests pass
13. ✅ All integration tests pass
14. ✅ Manual testing confirms working authentication

## Rationale

### Why This Approach is Correct

1. **Based on Official API Documentation**: Every decision comes directly from https://indielogin.com/api, not generic specs

2. **PKCE is Mandatory**: IndieLogin.com requires it for security (prevents authorization code interception)

3. **Simple Authentication Flow**: IndieLogin.com provides authentication (who are you?), not authorization (what can you access?). No scopes, no access tokens - just identity.

4. **No Client Registration**: IndieLogin.com accepts any valid client_id URL. No metadata endpoint, no h-app required.

5. **Correct Endpoints**:
   - `/authorize` - Start authentication (not `/auth`)
   - `/token` - Exchange code for identity (not `/auth`)

6. **Security Best Practices**:
   - State token prevents CSRF
   - PKCE prevents code interception
   - Issuer validation prevents token substitution
   - Single-use tokens

### Why Previous Approaches Failed

1. **ADR-016 (h-app)**: Added client discovery mechanism that IndieLogin.com doesn't use
2. **ADR-017 (OAuth metadata)**: Added OAuth endpoint that IndieLogin.com doesn't check
3. **Original implementation**: Missing PKCE, wrong endpoints, wrong parameters

### What We Learned

1. **Read the specific API docs first**, not generic specs
2. **IndieLogin.com is not a full OAuth 2.0 server** - it's a simplified authentication service
3. **PKCE is not optional** - it's required by IndieLogin.com
4. **Authentication ≠ Authorization** - we only need identity, not access tokens

## Consequences

### Positive

1. ✅ **Authentication Will Work**: Follows IndieLogin.com API exactly
2. ✅ **Simpler Codebase**: Removes ~73 lines of unnecessary code
3. ✅ **Better Security**: PKCE prevents authorization code attacks
4. ✅ **Standards Compliant**: Implements PKCE correctly
5. ✅ **Maintainable**: Less code, clearer purpose
6. ✅ **Testable**: Well-defined flow with clear inputs/outputs

### Negative

1. ⚠️ **Database Migration Required**: Must add code_verifier column
   - Mitigation: Simple ALTER TABLE, backward compatible
2. ⚠️ **Breaking Change for In-Flight Logins**: Users mid-authentication will need to restart
   - Mitigation: State tokens only live 5 minutes, minimal impact
3. ⚠️ **More Complex Auth Flow**: PKCE adds steps
   - Mitigation: Required by spec, improves security

### Neutral

1. **Code Complexity**: PKCE adds ~50 lines but removes ~73 lines (net -23 lines)
2. **Testing**: More test cases for PKCE, but clearer test boundaries

## Compliance

### IndieLogin.com API Compliance

- ✅ Uses `/authorize` endpoint for authentication
- ✅ Sends required parameters: client_id, redirect_uri, state
- ✅ Sends PKCE parameters: code_challenge, code_challenge_method
- ✅ Validates state in callback
- ✅ Validates iss in callback
- ✅ Uses `/token` endpoint for code exchange
- ✅ Sends code_verifier in token exchange
- ✅ Handles success response {"me": "url"}
- ✅ Handles error response {"error": "...", "error_description": "..."}

### PKCE Specification Compliance

- ✅ code_verifier: 43-128 character random string
- ✅ code_challenge: Base64-URL encoded SHA256 hash
- ✅ code_challenge_method: S256
- ✅ Verifier sent in token exchange
- ✅ Challenge sent in authorization request

### Project Standards Compliance

- ✅ Minimal code (removes unnecessary features)
- ✅ Standards-first (follows official API)
- ✅ Security best practices (PKCE, state, iss validation)
- ✅ "Every line must justify existence" (removes 73 lines that didn't)

## References

### Primary Source

- **IndieLogin.com API Documentation**: https://indielogin.com/api
  - This is the ONLY source for implementation decisions in this ADR

### Supporting Specifications

- **PKCE Specification (RFC 7636)**: https://www.rfc-editor.org/rfc/rfc7636
- **OAuth 2.0 (RFC 6749)**: https://www.rfc-editor.org/rfc/rfc6749
- **IndieAuth Specification**: https://www.w3.org/TR/indieauth/ (for context only)

### Internal Documentation

- ADR-005: IndieLogin Authentication Integration (conceptual flow)
- ADR-016: IndieAuth Client Discovery Mechanism (superseded)
- ADR-017: OAuth Client ID Metadata Document (superseded)

## Related ADRs

- **Supersedes**: ADR-016, ADR-017
- **Corrects**: ADR-005 (implementation details)
- **Relates to**: ADR-010 (Authentication Module Design)

## Version Impact

**Issue Type**: Critical Bug Fix (authentication completely broken)

**Version Change**: v0.6.2 → v0.6.3

**Semantic Versioning**: Patch increment (fixes broken functionality, no new features)

**Changelog Category**: Fixed

## Notes for Developer

### Key Implementation Points

1. **PKCE is non-negotiable** - don't skip it
2. **Endpoints matter** - /authorize and /token, not /auth
3. **code_verifier must be stored** - needed for step 4
4. **Validate everything** - state, iss, me URL
5. **Remove unnecessary code** - cleaner is better

### Common Pitfalls to Avoid

1. ❌ Forgetting to generate code_challenge from code_verifier
2. ❌ Not storing code_verifier in database
3. ❌ Using /auth endpoint instead of /authorize or /token
4. ❌ Not sending code_verifier in token exchange
5. ❌ Not validating iss parameter
6. ❌ Keeping unnecessary OAuth metadata code

### Debugging Tips

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

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

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

### Testing the Implementation

```python
# In Python REPL or test file
import requests

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

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

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

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

---

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