# Phase 3: Authentication Implementation Design

**Version**: 1.0
**Date**: 2025-11-18
**Architect**: StarPunk Architect Agent
**Target Module**: `starpunk/auth.py`
**Estimated LOC**: 250-300 lines
**Dependencies**: `database.py`, `utils.py`, `httpx`, `secrets`

## Executive Summary

This document provides the complete implementation design for Phase 3: Authentication. The module implements IndieLogin-based authentication with secure session management, CSRF protection, and single-admin authorization. This is a CRITICAL PATH component required before Phase 4 (Web Interface).

## Design Goals

1. **Zero Password Complexity** - No local password storage or management
2. **Industry-Standard Security** - Token hashing, CSRF protection, secure cookies
3. **Minimal Code** - Single module, ~300 lines total
4. **Full Test Coverage** - Target 90%+ coverage with security focus
5. **Production Ready** - Proper error handling, logging, session management

## Module Structure

### File: `starpunk/auth.py`

```python
"""
Authentication module for StarPunk

Implements IndieLogin authentication for admin access using indielogin.com
as a delegated authentication provider. No passwords are stored locally.

Security features:
- CSRF protection via state tokens
- Secure session tokens (cryptographically random)
- Token hashing in database (SHA-256)
- HttpOnly, Secure, SameSite cookies
- Single-admin authorization
- Automatic session cleanup

Functions:
    initiate_login: Start IndieLogin authentication flow
    handle_callback: Process IndieLogin callback
    create_session: Create authenticated session
    verify_session: Check if session is valid
    destroy_session: Logout and cleanup
    require_auth: Decorator for protected routes

Exceptions:
    AuthError: Base authentication exception
    InvalidStateError: CSRF state validation failed
    UnauthorizedError: User not authorized as admin
    IndieLoginError: External service error
"""
```

## Database Schema Updates

Add to `starpunk/database.py`:

```sql
-- Session management
CREATE TABLE IF NOT EXISTS sessions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_token_hash TEXT UNIQUE NOT NULL,  -- SHA-256 hash
    me TEXT NOT NULL,                         -- User's IndieWeb URL
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    last_used_at TIMESTAMP,
    user_agent TEXT,                          -- For session info
    ip_address TEXT                           -- For security audit
);

CREATE INDEX idx_sessions_token_hash ON sessions(session_token_hash);
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX idx_sessions_me ON sessions(me);

-- CSRF state tokens
CREATE TABLE IF NOT EXISTS auth_state (
    state TEXT PRIMARY KEY,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    redirect_uri TEXT
);

CREATE INDEX idx_auth_state_expires_at ON auth_state(expires_at);
```

## Core Functions Design

### 1. `initiate_login(me_url: str) -> str`

**Purpose**: Start the IndieLogin authentication flow

**Implementation**:
```python
def initiate_login(me_url: str) -> str:
    """
    Initiate IndieLogin authentication flow

    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("Invalid URL format")

    # Generate CSRF state token
    state = secrets.token_urlsafe(32)

    # Store state in database (5-minute expiry)
    db = get_db()
    expires_at = datetime.utcnow() + timedelta(minutes=5)

    db.execute("""
        INSERT INTO auth_state (state, expires_at, redirect_uri)
        VALUES (?, ?, ?)
    """, (state, expires_at, f"{SITE_URL}/auth/callback"))
    db.commit()

    # Build IndieLogin URL
    params = {
        'me': me_url,
        'client_id': current_app.config['SITE_URL'],
        'redirect_uri': f"{current_app.config['SITE_URL']}/auth/callback",
        'state': state,
        'response_type': 'code'
    }

    auth_url = f"https://indielogin.com/auth?{urlencode(params)}"

    # Log authentication attempt
    current_app.logger.info(f"Auth initiated for {me_url}")

    return auth_url
```

### 2. `handle_callback(code: str, state: str) -> Optional[str]`

**Purpose**: Process IndieLogin callback and create session

**Implementation**:
```python
def handle_callback(code: str, state: str) -> Optional[str]:
    """
    Handle IndieLogin callback

    Args:
        code: Authorization code from IndieLogin
        state: CSRF state token

    Returns:
        Session token if successful, None otherwise

    Raises:
        InvalidStateError: State token validation failed
        UnauthorizedError: User not authorized as admin
        IndieLoginError: Code exchange failed
    """
    # Verify state token (CSRF protection)
    if not _verify_state_token(state):
        raise InvalidStateError("Invalid or expired state token")

    # Exchange code for identity
    try:
        response = httpx.post('https://indielogin.com/auth',
            data={
                'code': code,
                'client_id': current_app.config['SITE_URL'],
                'redirect_uri': f"{current_app.config['SITE_URL']}/auth/callback"
            },
            timeout=10.0
        )
        response.raise_for_status()
    except httpx.RequestError as e:
        raise IndieLoginError(f"Failed to verify code: {e}")

    # Parse response
    data = response.json()
    me = data.get('me')

    if not me:
        raise IndieLoginError("No identity returned from IndieLogin")

    # Verify this is the admin user
    if me != current_app.config['ADMIN_ME']:
        current_app.logger.warning(f"Unauthorized login attempt: {me}")
        raise UnauthorizedError(f"User {me} is not authorized")

    # Create session
    session_token = create_session(me)

    return session_token
```

### 3. `create_session(me: str) -> str`

**Purpose**: Create a new authenticated session

**Implementation**:
```python
def create_session(me: str) -> str:
    """
    Create authenticated session

    Args:
        me: Verified user identity URL

    Returns:
        Session token (plaintext, to be sent as cookie)
    """
    # Generate secure token
    session_token = secrets.token_urlsafe(32)
    token_hash = _hash_token(session_token)

    # Calculate expiry (30 days)
    expires_at = datetime.utcnow() + timedelta(days=30)

    # Get request metadata
    user_agent = request.headers.get('User-Agent', '')[:200]
    ip_address = request.remote_addr

    # Store in database
    db = get_db()
    db.execute("""
        INSERT INTO sessions
        (session_token_hash, me, expires_at, user_agent, ip_address)
        VALUES (?, ?, ?, ?, ?)
    """, (token_hash, me, expires_at, user_agent, ip_address))
    db.commit()

    # Cleanup expired sessions
    _cleanup_expired_sessions()

    # Log session creation
    current_app.logger.info(f"Session created for {me}")

    return session_token
```

### 4. `verify_session(token: str) -> Optional[Dict[str, Any]]`

**Purpose**: Validate session token and return user info

**Implementation**:
```python
def verify_session(token: str) -> Optional[Dict[str, Any]]:
    """
    Verify session token

    Args:
        token: Session token from cookie

    Returns:
        Session info dict if valid, None otherwise
    """
    if not token:
        return None

    token_hash = _hash_token(token)

    db = get_db()
    session = db.execute("""
        SELECT id, me, created_at, expires_at, last_used_at
        FROM sessions
        WHERE session_token_hash = ?
        AND expires_at > datetime('now')
    """, (token_hash,)).fetchone()

    if not session:
        return None

    # Update last_used_at for activity tracking
    db.execute("""
        UPDATE sessions
        SET last_used_at = datetime('now')
        WHERE id = ?
    """, (session['id'],))
    db.commit()

    return {
        'me': session['me'],
        'created_at': session['created_at'],
        'expires_at': session['expires_at']
    }
```

### 5. `require_auth` Decorator

**Purpose**: Protect routes that require authentication

**Implementation**:
```python
def require_auth(f):
    """
    Decorator to require authentication for a route

    Usage:
        @app.route('/admin')
        @require_auth
        def admin_dashboard():
            return render_template('admin/dashboard.html')
    """
    @wraps(f)
    def decorated_function(*args, **kwargs):
        # Get session token from cookie
        session_token = request.cookies.get('session')

        # Verify session
        session_info = verify_session(session_token)

        if not session_info:
            # Store intended destination
            session['next'] = request.url
            return redirect(url_for('auth.login'))

        # Store user info in g for use in views
        g.user = session_info
        g.me = session_info['me']

        return f(*args, **kwargs)

    return decorated_function
```

## Helper Functions

### `_hash_token(token: str) -> str`
```python
def _hash_token(token: str) -> str:
    """Hash token using SHA-256"""
    import hashlib
    return hashlib.sha256(token.encode()).hexdigest()
```

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

    # Check if state exists and not expired
    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
```

### `_cleanup_expired_sessions() -> None`
```python
def _cleanup_expired_sessions() -> None:
    """Remove expired sessions and state tokens"""
    db = get_db()

    # Delete expired sessions
    db.execute("""
        DELETE FROM sessions
        WHERE expires_at <= datetime('now')
    """)

    # Delete expired state tokens
    db.execute("""
        DELETE FROM auth_state
        WHERE expires_at <= datetime('now')
    """)

    db.commit()
```

## Error Handling

### Custom Exceptions
```python
class AuthError(Exception):
    """Base exception for authentication errors"""
    pass

class InvalidStateError(AuthError):
    """CSRF state validation failed"""
    pass

class UnauthorizedError(AuthError):
    """User not authorized as admin"""
    pass

class IndieLoginError(AuthError):
    """IndieLogin service error"""
    pass
```

### Error Responses
- **Invalid state**: Return 400 with "Invalid authentication state"
- **Unauthorized**: Return 403 with "Access denied"
- **IndieLogin error**: Return 502 with "Authentication service error"
- **Session expired**: Redirect to login with flash message

## Security Considerations

### Token Security
1. **Generation**: Use `secrets.token_urlsafe(32)` (256 bits entropy)
2. **Storage**: Store SHA-256 hash, never plaintext
3. **Transmission**: HttpOnly, Secure, SameSite=Lax cookies
4. **Rotation**: New token on each login

### CSRF Protection
1. **State tokens**: Random, single-use, 5-minute expiry
2. **Validation**: Check state before code exchange
3. **Cleanup**: Delete after use

### Session Security
1. **Expiry**: 30 days with activity refresh
2. **Invalidation**: Explicit logout deletes session
3. **Metadata**: Store IP and user agent for audit
4. **Cleanup**: Periodic removal of expired sessions

## Testing Requirements

### Unit Tests (`tests/test_auth.py`)

1. **Authentication Flow**
   - Test successful login flow
   - Test invalid me_url rejection
   - Test state token generation
   - Test state token expiry

2. **Callback Handling**
   - Test successful callback
   - Test invalid state rejection
   - Test unauthorized user rejection
   - Test IndieLogin error handling

3. **Session Management**
   - Test session creation
   - Test session verification
   - Test session expiry
   - Test session destruction

4. **Security Tests**
   - Test token hashing
   - Test CSRF protection
   - Test SQL injection prevention
   - Test path traversal attempts

5. **Decorator Tests**
   - Test require_auth with valid session
   - Test require_auth with expired session
   - Test require_auth with no session

### Integration Tests
- Mock IndieLogin.com responses
- Test full authentication flow
- Test error scenarios
- Test session persistence

## Configuration Requirements

Required environment variables:
```bash
# .env
SITE_URL=https://starpunk.example.com
ADMIN_ME=https://yoursite.com
SESSION_SECRET=<random-32-byte-hex>
INDIELOGIN_URL=https://indielogin.com  # Optional override
```

## Implementation Checklist

- [ ] Create `starpunk/auth.py` module
- [ ] Add session tables to `database.py`
- [ ] Implement `initiate_login` function
- [ ] Implement `handle_callback` function
- [ ] Implement `create_session` function
- [ ] Implement `verify_session` function
- [ ] Implement `destroy_session` function
- [ ] Create `require_auth` decorator
- [ ] Add helper functions
- [ ] Add exception classes
- [ ] Write unit tests (90% coverage target)
- [ ] Write integration tests
- [ ] Add security logging
- [ ] Update configuration documentation
- [ ] Security audit checklist

## Acceptance Criteria

1. **Functional Requirements**
   - Admin can login via IndieLogin
   - Only configured admin can authenticate
   - Sessions persist across server restarts
   - Logout destroys session
   - Protected routes require authentication

2. **Security Requirements**
   - All tokens properly hashed
   - CSRF protection working
   - No SQL injection vulnerabilities
   - Sessions expire after 30 days
   - Failed logins are logged

3. **Performance Requirements**
   - Login completes in < 3 seconds
   - Session verification < 10ms
   - Cleanup doesn't block requests

4. **Quality Requirements**
   - 90%+ test coverage
   - All functions documented
   - Security best practices followed
   - Error messages are helpful

## Next Steps

After Phase 3 completion:
1. **Phase 4**: Web Interface (public and admin routes)
2. **Phase 5**: RSS Feed generation
3. **Phase 6**: Micropub endpoint

## References

- [ADR-005: IndieLogin Authentication](/home/phil/Projects/starpunk/docs/decisions/ADR-005-indielogin-authentication.md)
- [ADR-010: Authentication Module Design](/home/phil/Projects/starpunk/docs/decisions/ADR-010-authentication-module-design.md)
- [IndieAuth Specification](https://www.w3.org/TR/indieauth/)
- [IndieLogin API Documentation](https://indielogin.com/api)
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)

---

**Document Version**: 1.0
**Last Updated**: 2025-11-18
**Status**: Ready for Implementation