Files
StarPunk/docs/design/phase-3-authentication-implementation.md
Phil Skentelbery d4f1bfb198 feat: Implement Phase 3 authentication module with IndieLogin support
Implement complete authentication system following ADR-010 and Phase 3 design specs.
This is a MINOR version increment (0.3.0 -> 0.4.0) as it adds new functionality.

Authentication Features:
- IndieLogin authentication flow via indielogin.com
- Secure session management with SHA-256 token hashing
- CSRF protection with single-use state tokens
- Session lifecycle (create, verify, destroy)
- require_auth decorator for protected routes
- Automatic cleanup of expired sessions
- IP address and user agent tracking

Security Measures:
- Cryptographically secure token generation (secrets module)
- Token hashing for storage (never plaintext)
- SQL injection prevention (prepared statements)
- Single-use CSRF state tokens
- 30-day session expiry with activity refresh
- Comprehensive security logging

Implementation Details:
- starpunk/auth.py: 406 lines, 6 core functions, 4 helpers, 4 exceptions
- tests/test_auth.py: 648 lines, 37 tests, 96% coverage
- Database schema updates for sessions and auth_state tables
- URL validation utility added to utils.py

Test Coverage:
- 37 authentication tests
- 96% code coverage (exceeds 90% target)
- All security features tested
- Edge cases and error paths covered

Documentation:
- Implementation report in docs/reports/
- Updated CHANGELOG.md with detailed changes
- Version incremented to 0.4.0
- ADR-010 and Phase 3 design docs included

Follows project standards:
- Black code formatting (88 char lines)
- Flake8 linting (no errors)
- Python coding standards
- Type hints on all functions
- Comprehensive docstrings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 20:35:36 -07:00

15 KiB

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

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

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

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:

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:

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:

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:

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

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

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

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

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:

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


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