Files
StarPunk/starpunk/config.py
Phil Skentelbery 80bd51e4c1 fix: Implement IndieAuth endpoint discovery (v1.0.0-rc.5)
CRITICAL: Fix hardcoded IndieAuth endpoint configuration that violated
the W3C IndieAuth specification. Endpoints are now discovered dynamically
from the user's profile URL as required by the spec.

This combines two critical fixes for v1.0.0-rc.5:
1. Migration race condition fix (previously committed)
2. IndieAuth endpoint discovery (this commit)

## What Changed

### Endpoint Discovery Implementation
- Completely rewrote starpunk/auth_external.py with full endpoint discovery
- Implements W3C IndieAuth specification Section 4.2 (Discovery by Clients)
- Supports HTTP Link headers and HTML link elements for discovery
- Always discovers from ADMIN_ME (single-user V1 assumption)
- Endpoint caching (1 hour TTL) for performance
- Token verification caching (5 minutes TTL)
- Graceful fallback to expired cache on network failures

### Breaking Changes
- REMOVED: TOKEN_ENDPOINT configuration variable
- Endpoints now discovered automatically from ADMIN_ME profile
- ADMIN_ME profile must include IndieAuth link elements or headers
- Deprecation warning shown if TOKEN_ENDPOINT still in environment

### Added
- New dependency: beautifulsoup4>=4.12.0 for HTML parsing
- HTTP Link header parsing (RFC 8288 basic support)
- HTML link element extraction with BeautifulSoup4
- Relative URL resolution against profile URL
- HTTPS enforcement in production (HTTP allowed in debug mode)
- Comprehensive error handling with clear messages
- 35 new tests covering all discovery scenarios

### Security
- Token hashing (SHA-256) for secure caching
- HTTPS required in production, localhost only in debug mode
- URL validation prevents injection
- Fail closed on security errors
- Single-user validation (token must belong to ADMIN_ME)

### Performance
- Cold cache: ~700ms (first request per hour)
- Warm cache: ~2ms (subsequent requests)
- Grace period maintains service during network issues

## Testing
- 536 tests passing (excluding timing-sensitive migration tests)
- 35 new endpoint discovery tests (all passing)
- Zero regressions in existing functionality

## Documentation
- Updated CHANGELOG.md with comprehensive v1.0.0-rc.5 entry
- Implementation report: docs/reports/2025-11-24-v1.0.0-rc.5-implementation.md
- Migration guide: docs/migration/fix-hardcoded-endpoints.md (architect)
- ADR-031: Endpoint Discovery Implementation Details (architect)

## Migration Required
1. Ensure ADMIN_ME profile has IndieAuth link elements
2. Remove TOKEN_ENDPOINT from .env file
3. Restart StarPunk - endpoints discovered automatically

Following:
- ADR-031: Endpoint Discovery Implementation Details
- docs/architecture/endpoint-discovery-answers.md (architect Q&A)
- docs/architecture/indieauth-endpoint-discovery.md (architect guide)
- W3C IndieAuth Specification Section 4.2

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 19:41:39 -07:00

147 lines
5.4 KiB
Python

"""
Configuration management for StarPunk
Loads settings from environment variables and .env file
"""
import os
from pathlib import Path
from dotenv import load_dotenv
def load_config(app, config_override=None):
"""
Load configuration into Flask app
Args:
app: Flask application instance
config_override: Optional dict to override config values
"""
# Load .env file
load_dotenv()
# Site configuration
# IndieWeb/OAuth specs require trailing slash for root URLs used as client_id
# See: https://indielogin.com/ OAuth client requirements
site_url = os.getenv("SITE_URL", "http://localhost:5000")
app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/'
app.config["SITE_NAME"] = os.getenv("SITE_NAME", "StarPunk")
app.config["SITE_AUTHOR"] = os.getenv("SITE_AUTHOR", "Unknown")
app.config["SITE_DESCRIPTION"] = os.getenv(
"SITE_DESCRIPTION", "A minimal IndieWeb CMS"
)
# Authentication
app.config["ADMIN_ME"] = os.getenv("ADMIN_ME")
app.config["SESSION_SECRET"] = os.getenv("SESSION_SECRET")
app.config["SESSION_LIFETIME"] = int(os.getenv("SESSION_LIFETIME", "30"))
app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com")
# DEPRECATED: TOKEN_ENDPOINT no longer used (v1.0.0-rc.5+)
# Endpoints are now discovered from ADMIN_ME profile (ADR-031)
if 'TOKEN_ENDPOINT' in os.environ:
app.logger.warning(
"TOKEN_ENDPOINT is deprecated and will be ignored. "
"Remove it from your configuration. "
"Endpoints are now discovered automatically from your ADMIN_ME profile. "
"See docs/migration/fix-hardcoded-endpoints.md for details."
)
# Validate required configuration
if not app.config["SESSION_SECRET"]:
raise ValueError(
"SESSION_SECRET must be set in .env file. "
'Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"'
)
# Flask secret key (uses SESSION_SECRET by default)
# Note: We check for truthy value to handle empty string in .env
flask_secret = os.getenv("FLASK_SECRET_KEY")
app.config["SECRET_KEY"] = flask_secret if flask_secret else app.config["SESSION_SECRET"]
# Data paths
app.config["DATA_PATH"] = Path(os.getenv("DATA_PATH", "./data"))
app.config["NOTES_PATH"] = Path(os.getenv("NOTES_PATH", "./data/notes"))
app.config["DATABASE_PATH"] = Path(os.getenv("DATABASE_PATH", "./data/starpunk.db"))
# Flask environment
app.config["ENV"] = os.getenv("FLASK_ENV", "development")
app.config["DEBUG"] = os.getenv("FLASK_DEBUG", "1") == "1"
# Logging
app.config["LOG_LEVEL"] = os.getenv("LOG_LEVEL", "INFO")
# Development mode configuration
app.config["DEV_MODE"] = os.getenv("DEV_MODE", "false").lower() == "true"
app.config["DEV_ADMIN_ME"] = os.getenv("DEV_ADMIN_ME", "")
# Application version (use __version__ from package)
from starpunk import __version__
app.config["VERSION"] = os.getenv("VERSION", __version__)
# RSS feed configuration
app.config["FEED_MAX_ITEMS"] = int(os.getenv("FEED_MAX_ITEMS", "50"))
app.config["FEED_CACHE_SECONDS"] = int(os.getenv("FEED_CACHE_SECONDS", "300"))
# Apply overrides if provided
if config_override:
app.config.update(config_override)
# Normalize SITE_URL trailing slash (in case override provided URL without slash)
if "SITE_URL" in app.config:
site_url = app.config["SITE_URL"]
app.config["SITE_URL"] = site_url if site_url.endswith('/') else site_url + '/'
# Convert path strings to Path objects (in case overrides provided strings)
if isinstance(app.config["DATA_PATH"], str):
app.config["DATA_PATH"] = Path(app.config["DATA_PATH"])
if isinstance(app.config["NOTES_PATH"], str):
app.config["NOTES_PATH"] = Path(app.config["NOTES_PATH"])
if isinstance(app.config["DATABASE_PATH"], str):
app.config["DATABASE_PATH"] = Path(app.config["DATABASE_PATH"])
# Validate configuration
validate_config(app)
# Ensure data directories exist
app.config["DATA_PATH"].mkdir(parents=True, exist_ok=True)
app.config["NOTES_PATH"].mkdir(parents=True, exist_ok=True)
def validate_config(app):
"""
Validate application configuration on startup
Ensures required configuration is present based on mode (dev/production)
and warns prominently if development mode is enabled.
Args:
app: Flask application instance
Raises:
ValueError: If required configuration is missing
"""
dev_mode = app.config.get("DEV_MODE", False)
if dev_mode:
# Prominently warn about development mode
app.logger.warning(
"=" * 60 + "\n"
"WARNING: Development authentication enabled!\n"
"This should NEVER be used in production.\n"
"Set DEV_MODE=false for production deployments.\n" + "=" * 60
)
# Require DEV_ADMIN_ME in dev mode
if not app.config.get("DEV_ADMIN_ME"):
raise ValueError(
"DEV_MODE=true requires DEV_ADMIN_ME to be set. "
"Set DEV_ADMIN_ME=https://your-dev-identity.example.com in .env"
)
else:
# Production mode: ADMIN_ME is required
if not app.config.get("ADMIN_ME"):
raise ValueError(
"Production mode requires ADMIN_ME to be set. "
"Set ADMIN_ME=https://your-site.com in .env"
)