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>
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
- Zero Password Complexity - No local password storage or management
- Industry-Standard Security - Token hashing, CSRF protection, secure cookies
- Minimal Code - Single module, ~300 lines total
- Full Test Coverage - Target 90%+ coverage with security focus
- 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
- Generation: Use
secrets.token_urlsafe(32)(256 bits entropy) - Storage: Store SHA-256 hash, never plaintext
- Transmission: HttpOnly, Secure, SameSite=Lax cookies
- Rotation: New token on each login
CSRF Protection
- State tokens: Random, single-use, 5-minute expiry
- Validation: Check state before code exchange
- Cleanup: Delete after use
Session Security
- Expiry: 30 days with activity refresh
- Invalidation: Explicit logout deletes session
- Metadata: Store IP and user agent for audit
- Cleanup: Periodic removal of expired sessions
Testing Requirements
Unit Tests (tests/test_auth.py)
-
Authentication Flow
- Test successful login flow
- Test invalid me_url rejection
- Test state token generation
- Test state token expiry
-
Callback Handling
- Test successful callback
- Test invalid state rejection
- Test unauthorized user rejection
- Test IndieLogin error handling
-
Session Management
- Test session creation
- Test session verification
- Test session expiry
- Test session destruction
-
Security Tests
- Test token hashing
- Test CSRF protection
- Test SQL injection prevention
- Test path traversal attempts
-
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.pymodule - Add session tables to
database.py - Implement
initiate_loginfunction - Implement
handle_callbackfunction - Implement
create_sessionfunction - Implement
verify_sessionfunction - Implement
destroy_sessionfunction - Create
require_authdecorator - 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
-
Functional Requirements
- Admin can login via IndieLogin
- Only configured admin can authenticate
- Sessions persist across server restarts
- Logout destroys session
- Protected routes require authentication
-
Security Requirements
- All tokens properly hashed
- CSRF protection working
- No SQL injection vulnerabilities
- Sessions expire after 30 days
- Failed logins are logged
-
Performance Requirements
- Login completes in < 3 seconds
- Session verification < 10ms
- Cleanup doesn't block requests
-
Quality Requirements
- 90%+ test coverage
- All functions documented
- Security best practices followed
- Error messages are helpful
Next Steps
After Phase 3 completion:
- Phase 4: Web Interface (public and admin routes)
- Phase 5: RSS Feed generation
- Phase 6: Micropub endpoint
References
- ADR-005: IndieLogin Authentication
- ADR-010: Authentication Module Design
- IndieAuth Specification
- IndieLogin API Documentation
- OWASP Authentication Cheat Sheet
Document Version: 1.0 Last Updated: 2025-11-18 Status: Ready for Implementation