# IndieAuth Endpoint Discovery Hotfix

**Date:** 2025-12-17
**Type:** Production Hotfix
**Priority:** Critical
**Status:** Ready for Implementation

## Problem Summary

Users cannot log in to StarPunk. The root cause is that the authentication code ignores endpoint discovery and hardcodes `INDIELOGIN_URL` instead of discovering the authorization and token endpoints from the user's profile URL.

**Root Cause:** The `starpunk/auth.py` module uses `INDIELOGIN_URL` config instead of discovering endpoints from the user's profile URL as required by the IndieAuth specification. This is a regression - the system used to respect discovered endpoints.

**Note:** The PKCE error message in the callback is a symptom, not the cause. Once we use the correct discovered endpoints, PKCE will not be required (since the user's actual IndieAuth server doesn't require it).

## Specification Requirements

### W3C IndieAuth Spec (https://www.w3.org/TR/indieauth/)

- Clients MUST discover `authorization_endpoint` from user's profile URL
- Clients MUST discover `token_endpoint` from user's profile URL
- Discovery via HTTP Link headers (highest priority) or HTML `<link>` elements

## Implementation Plan

### Overview

The fix reuses the existing `discover_endpoints()` function from `auth_external.py` in the login flow. Changes are minimal and focused:

1. Use `discover_endpoints()` in `initiate_login()` to get the `authorization_endpoint`
2. Use `discover_endpoints()` in `handle_callback()` to get the `token_endpoint`
3. Remove `INDIELOGIN_URL` config (with deprecation warning)

---

### Step 1: Update config.py - Remove INDIELOGIN_URL

In `/home/phil/Projects/starpunk/starpunk/config.py`:

**Change 1:** Remove the INDIELOGIN_URL config line (line 37):

```python
# DELETE THIS LINE:
app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com")
```

**Change 2:** Add deprecation warning for INDIELOGIN_URL (add after the TOKEN_ENDPOINT warning, around line 47):

```python
# DEPRECATED: INDIELOGIN_URL no longer used (hotfix 2025-12-17)
# Authorization endpoint is now discovered from ADMIN_ME profile per IndieAuth spec
if 'INDIELOGIN_URL' in os.environ:
    app.logger.warning(
        "INDIELOGIN_URL is deprecated and will be ignored. "
        "Remove it from your configuration. "
        "The authorization endpoint is now discovered automatically from your ADMIN_ME profile."
    )
```

---

### Step 2: Update auth.py - Use Endpoint Discovery

In `/home/phil/Projects/starpunk/starpunk/auth.py`:

**Change 1:** Add import for endpoint discovery (after line 42):

```python
from starpunk.auth_external import discover_endpoints, DiscoveryError, normalize_url
```

> **Note:** The `normalize_url` import is at the top level (not inside `handle_callback()`) for consistency with the existing code style.

**Change 2:** Update `initiate_login()` to use discovered authorization_endpoint (replace lines 251-318):

```python
def initiate_login(me_url: str) -> str:
    """
    Initiate IndieAuth authentication flow with endpoint discovery.

    Per W3C IndieAuth spec, discovers authorization_endpoint from user's
    profile URL rather than using a hardcoded service.

    Args:
        me_url: User's IndieWeb identity URL

    Returns:
        Redirect URL to discovered authorization endpoint

    Raises:
        ValueError: Invalid me_url format
        DiscoveryError: Failed to discover endpoints from profile
    """
    # 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}")

    # Discover authorization endpoint from user's profile URL
    # Per IndieAuth spec: clients MUST discover endpoints, not hardcode them
    try:
        endpoints = discover_endpoints(me_url)
    except DiscoveryError as e:
        current_app.logger.error(f"Auth: Endpoint discovery failed for {me_url}: {e}")
        raise

    auth_endpoint = endpoints.get('authorization_endpoint')
    if not auth_endpoint:
        raise DiscoveryError(
            f"No authorization_endpoint found at {me_url}. "
            "Ensure your profile has IndieAuth link elements or headers."
        )

    current_app.logger.info(f"Auth: Discovered authorization_endpoint: {auth_endpoint}")

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

    # Store state 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, expires_at, redirect_uri)
        VALUES (?, ?, ?)
        """,
        (state, expires_at, redirect_uri),
    )
    db.commit()

    # Build authorization URL
    params = {
        "me": me_url,
        "client_id": current_app.config["SITE_URL"],
        "redirect_uri": redirect_uri,
        "state": state,
        "response_type": "code",
    }

    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"  response_type: code"
    )

    auth_url = f"{auth_endpoint}?{urlencode(params)}"

    current_app.logger.debug(f"Auth: Complete authorization URL: {auth_url}")
    current_app.logger.info(f"Auth: Authentication initiated for {me_url}")

    return auth_url
```

**Change 3:** Update `handle_callback()` to use discovered authorization_endpoint (replace lines 321-474):

> **Important:** Per IndieAuth spec, authentication-only flows (identity verification without access tokens) POST to the **authorization_endpoint**, NOT the token_endpoint. The `grant_type` parameter is NOT included for authentication-only flows.

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

    Discovers authorization_endpoint from ADMIN_ME profile and exchanges
    authorization code for identity verification.

    Per IndieAuth spec: Authentication-only flows POST to the authorization
    endpoint (not token endpoint) and do not include grant_type.

    Args:
        code: Authorization code from authorization server
        state: CSRF state token
        iss: Issuer identifier (optional, for security validation)

    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 (CSRF protection)
    if not _verify_state_token(state):
        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")

    # Discover authorization endpoint from ADMIN_ME profile
    admin_me = current_app.config.get("ADMIN_ME")
    if not admin_me:
        current_app.logger.error("Auth: ADMIN_ME not configured")
        raise IndieLoginError("ADMIN_ME not configured")

    try:
        endpoints = discover_endpoints(admin_me)
    except DiscoveryError as e:
        current_app.logger.error(f"Auth: Endpoint discovery failed: {e}")
        raise IndieLoginError(f"Failed to discover endpoints: {e}")

    # Use authorization_endpoint for authentication-only flow (identity verification)
    # Per IndieAuth spec: auth-only flows POST to authorization_endpoint, not token_endpoint
    auth_endpoint = endpoints.get('authorization_endpoint')
    if not auth_endpoint:
        raise IndieLoginError(
            f"No authorization_endpoint found at {admin_me}. "
            "Ensure your profile has IndieAuth endpoints configured."
        )

    current_app.logger.debug(f"Auth: Using authorization_endpoint: {auth_endpoint}")

    # Verify issuer if provided (security check - optional)
    if iss:
        current_app.logger.debug(f"Auth: Issuer provided: {iss}")

    # Prepare code verification request
    # Note: grant_type is NOT included for authentication-only flows per IndieAuth spec
    token_exchange_data = {
        "code": code,
        "client_id": current_app.config["SITE_URL"],
        "redirect_uri": f"{current_app.config['SITE_URL']}auth/callback",
    }

    # Log the request
    _log_http_request(
        method="POST",
        url=auth_endpoint,
        data=token_exchange_data,
    )

    current_app.logger.debug(
        "Auth: Sending code verification request to authorization endpoint:\n"
        "  Method: POST\n"
        "  URL: %s\n"
        "  Data: code=%s, client_id=%s, redirect_uri=%s",
        auth_endpoint,
        _redact_token(code),
        token_exchange_data["client_id"],
        token_exchange_data["redirect_uri"],
    )

    # Exchange code for identity at authorization endpoint (authentication-only flow)
    try:
        response = httpx.post(
            auth_endpoint,
            data=token_exchange_data,
            timeout=10.0,
        )

        current_app.logger.debug(
            "Auth: Received code verification response:\n"
            "  Status: %d\n"
            "  Headers: %s\n"
            "  Body: %s",
            response.status_code,
            {k: v for k, v in dict(response.headers).items()
             if k.lower() not in ["set-cookie", "authorization"]},
            _redact_token(response.text) if response.text else "(empty)",
        )

        _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: Authorization endpoint request failed: {e}")
        raise IndieLoginError(f"Failed to verify code: {e}")
    except httpx.HTTPStatusError as e:
        current_app.logger.error(
            f"Auth: Authorization endpoint returned error: {e.response.status_code} - {e.response.text}"
        )
        raise IndieLoginError(
            f"Authorization endpoint 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 authorization endpoint response: {e}")
        raise IndieLoginError("Invalid JSON response from authorization endpoint")

    me = data.get("me")

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

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

    # Verify this is the admin user
    current_app.logger.info(f"Auth: Verifying admin authorization for me={me}")

    # Normalize URLs for comparison (handles trailing slashes and case differences)
    # This is correct per IndieAuth spec - the returned 'me' is the canonical form
    if normalize_url(me) != normalize_url(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)

    # Trigger author profile discovery (v1.2.0 Phase 2)
    # Per Q14: Never block login, always allow fallback
    try:
        from starpunk.author_discovery import get_author_profile
        author_profile = get_author_profile(me, refresh=True)
        current_app.logger.info(f"Author profile refreshed for {me}")
    except Exception as e:
        current_app.logger.warning(f"Author discovery failed: {e}")
        # Continue login anyway - never block per Q14

    return session_token
```

---

### Step 3: Update auth_external.py - Relax Endpoint Validation

The existing `_fetch_and_parse()` function requires `token_endpoint` to be present. We need to relax this since some profiles may only have `authorization_endpoint` (for authentication-only flows).

In `/home/phil/Projects/starpunk/starpunk/auth_external.py`, update the validation in `_fetch_and_parse()` (around lines 302-307):

**Change:** Make token_endpoint not strictly required (allow authentication-only profiles):

```python
# Validate we found at least one endpoint
# - authorization_endpoint: Required for authentication-only flows (admin login)
# - token_endpoint: Required for Micropub token verification
# Having at least one allows the appropriate flow to work
if 'token_endpoint' not in endpoints and 'authorization_endpoint' not in endpoints:
    raise DiscoveryError(
        f"No IndieAuth endpoints found at {profile_url}. "
        "Ensure your profile has authorization_endpoint or token_endpoint configured."
    )
```

---

### Step 4: Update routes/auth.py - Handle DiscoveryError

In `/home/phil/Projects/starpunk/starpunk/routes/auth.py`:

**Change 1:** Add import for DiscoveryError (update lines 20-29):

```python
from starpunk.auth import (
    IndieLoginError,
    InvalidStateError,
    UnauthorizedError,
    destroy_session,
    handle_callback,
    initiate_login,
    require_auth,
    verify_session,
)
from starpunk.auth_external import DiscoveryError
```

**Change 2:** Handle DiscoveryError in login_initiate() (update lines 79-85):

> **Note:** The user-facing error message is kept simple. Technical details are logged but not shown to users.

```python
    try:
        # Initiate IndieAuth flow
        auth_url = initiate_login(me_url)
        return redirect(auth_url)
    except ValueError as e:
        flash(str(e), "error")
        return redirect(url_for("auth.login_form"))
    except DiscoveryError as e:
        current_app.logger.error(f"Endpoint discovery failed for {me_url}: {e}")
        flash("Unable to verify your profile URL. Please check that it's correct and try again.", "error")
        return redirect(url_for("auth.login_form"))
```

---

## File Summary

| File | Change Type | Description |
|------|-------------|-------------|
| `starpunk/config.py` | Edit | Remove INDIELOGIN_URL, add deprecation warning |
| `starpunk/auth.py` | Edit | Use endpoint discovery instead of hardcoded URL |
| `starpunk/auth_external.py` | Edit | Relax endpoint validation (allow auth-only flow) |
| `starpunk/routes/auth.py` | Edit | Handle DiscoveryError exception |

---

## Testing Requirements

### Manual Testing

1. **Login Flow Test**
   - Navigate to `/auth/login`
   - Enter ADMIN_ME URL
   - Verify redirect goes to discovered authorization_endpoint (not hardcoded indielogin.com)
   - Complete login and verify session is created

2. **Endpoint Discovery Test**
   - Test with profile that declares custom endpoints
   - Verify discovered endpoints are used, not defaults

### Existing Test Updates

**Update test fixture in `tests/test_auth.py`:**

Remove `INDIELOGIN_URL` from the app fixture (line 51):

```python
@pytest.fixture
def app(tmp_path):
    """Create Flask app for testing"""
    from starpunk import create_app

    test_data_dir = tmp_path / "data"
    test_data_dir.mkdir(parents=True, exist_ok=True)

    app = create_app(
        {
            "TESTING": True,
            "SITE_URL": "http://localhost:5000/",
            "ADMIN_ME": "https://example.com",
            "SESSION_SECRET": secrets.token_hex(32),
            "SESSION_LIFETIME": 30,
            # REMOVED: "INDIELOGIN_URL": "https://indielogin.com",
            "DATA_PATH": test_data_dir,
            "NOTES_PATH": test_data_dir / "notes",
            "DATABASE_PATH": test_data_dir / "starpunk.db",
        }
    )
    return app
```

**Update existing tests that use `httpx.post` mock:**

Tests in `TestInitiateLogin` and `TestHandleCallback` need to mock `discover_endpoints()` in addition to `httpx.post`. Example pattern:

```python
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_success(self, mock_post, mock_discover, app, db, client):
    """Test successful callback handling"""
    # Mock endpoint discovery
    mock_discover.return_value = {
        'authorization_endpoint': 'https://auth.example.com/authorize',
        'token_endpoint': 'https://auth.example.com/token'
    }

    # Rest of test remains the same...
```

**Update `TestInitiateLogin.test_initiate_login_success`:**

The assertion checking for `indielogin.com` needs to change to check for the mocked endpoint:

```python
@patch("starpunk.auth.discover_endpoints")
def test_initiate_login_success(self, mock_discover, app, db):
    """Test successful login initiation"""
    mock_discover.return_value = {
        'authorization_endpoint': 'https://auth.example.com/authorize',
        'token_endpoint': 'https://auth.example.com/token'
    }

    with app.app_context():
        me_url = "https://example.com"
        auth_url = initiate_login(me_url)

        # Changed: Check for discovered endpoint instead of indielogin.com
        assert "auth.example.com/authorize" in auth_url
        assert "me=https%3A%2F%2Fexample.com" in auth_url
        # ... rest of assertions
```

### New Automated Tests to Add

```python
# tests/test_auth_endpoint_discovery.py

def test_initiate_login_uses_endpoint_discovery(client, mocker):
    """Verify login uses discovered endpoint, not hardcoded"""
    mock_discover = mocker.patch('starpunk.auth.discover_endpoints')
    mock_discover.return_value = {
        'authorization_endpoint': 'https://custom-auth.example.com/authorize',
        'token_endpoint': 'https://custom-auth.example.com/token'
    }

    response = client.post('/auth/login', data={'me': 'https://example.com'})

    assert response.status_code == 302
    assert 'custom-auth.example.com' in response.headers['Location']


def test_callback_uses_discovered_authorization_endpoint(client, mocker):
    """Verify callback uses discovered authorization endpoint (not token endpoint)"""
    mock_discover = mocker.patch('starpunk.auth.discover_endpoints')
    mock_discover.return_value = {
        'authorization_endpoint': 'https://custom-auth.example.com/authorize',
        'token_endpoint': 'https://custom-auth.example.com/token'
    }
    mock_post = mocker.patch('starpunk.auth.httpx.post')
    # Setup state token and mock httpx response
    # Verify code exchange POSTs to authorization_endpoint, not token_endpoint
    pass


def test_discovery_error_shows_user_friendly_message(client, mocker):
    """Verify discovery failures show helpful error"""
    mock_discover = mocker.patch('starpunk.auth.discover_endpoints')
    mock_discover.side_effect = DiscoveryError("No endpoints found")

    response = client.post('/auth/login', data={'me': 'https://example.com'})

    assert response.status_code == 302
    # Should redirect back to login form with flash message


def test_url_normalization_handles_trailing_slash(app, mocker):
    """Verify URL normalization allows trailing slash differences"""
    # ADMIN_ME without trailing slash, auth server returns with trailing slash
    # Should still authenticate successfully
    pass


def test_url_normalization_handles_case_differences(app, mocker):
    """Verify URL normalization is case-insensitive"""
    # ADMIN_ME: https://Example.com, auth server returns: https://example.com
    # Should still authenticate successfully
    pass
```

---

## Rollback Plan

If issues occur after deployment:

1. **Code:** Revert to previous commit
2. **Config:** Re-add INDIELOGIN_URL to .env if needed

---

## Post-Deployment Verification

1. Verify login works with the user's actual profile URL
2. Check logs for "Discovered authorization_endpoint" message
3. Test logout and re-login cycle

---

## Architect Q&A (2025-12-17)

Developer questions answered by the architect prior to implementation:

### Q1: Import Placement
**Q:** Should `normalize_url` import be inside the function or at top level?
**A:** Move to top level with other imports for consistency. The design has been updated.

### Q2: URL Normalization Behavior Change
**Q:** Is the URL normalization change intentional?
**A:** Yes, this is an intentional bugfix. The current exact-match behavior is incorrect per IndieAuth spec. URLs differing only in trailing slashes or case should be considered equivalent for identity purposes. The `normalize_url()` function already exists in `auth_external.py` and is used by `verify_external_token()`.

### Q3: Which Endpoint for Authentication Flow?
**Q:** Should we use token_endpoint or authorization_endpoint?
**A:** Use **authorization_endpoint** for authentication-only flows. Per IndieAuth spec: "the client makes a POST request to the authorization endpoint to verify the authorization code and retrieve the final user profile URL." The design has been corrected.

### Q4: Endpoint Validation Relaxation
**Q:** Is relaxed endpoint validation acceptable?
**A:** Yes. Login requires `authorization_endpoint`, Micropub requires `token_endpoint`. Requiring at least one is correct. If only auth endpoint exists, login works but Micropub fails gracefully (401).

### Q5: Test Update Strategy
**Q:** Remove INDIELOGIN_URL and/or mock discover_endpoints()?
**A:** Both. Remove `INDIELOGIN_URL` from fixtures, add `discover_endpoints()` mocking to existing tests. Detailed guidance added to Testing Requirements section.

### Q6: grant_type Parameter
**Q:** Should we include grant_type in the code exchange?
**A:** No. Authentication-only flows do not include `grant_type`. This parameter is only required when POSTing to the token_endpoint for access tokens. The design has been corrected.

### Q7: Error Message Verbosity
**Q:** Should we simplify the user-facing error message?
**A:** Yes. User-facing message should be simple: "Unable to verify your profile URL. Please check that it's correct and try again." Technical details are logged at ERROR level. The design has been updated.

---

## References

- W3C IndieAuth Specification: https://www.w3.org/TR/indieauth/
- IndieAuth Endpoint Discovery: https://www.w3.org/TR/indieauth/#discovery-by-clients
