# 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= 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://indieauth.spec.indieweb.org/) - [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