feat: Complete IndieAuth server removal (Phases 2-4)

Completed all remaining phases of ADR-030 IndieAuth provider removal.
StarPunk no longer acts as an authorization server - all IndieAuth
operations delegated to external providers.

Phase 2 - Remove Token Issuance:
- Deleted /auth/token endpoint
- Removed token_endpoint() function from routes/auth.py
- Deleted tests/test_routes_token.py

Phase 3 - Remove Token Storage:
- Deleted starpunk/tokens.py module entirely
- Created migration 004 to drop tokens and authorization_codes tables
- Deleted tests/test_tokens.py
- Removed all internal token CRUD operations

Phase 4 - External Token Verification:
- Created starpunk/auth_external.py module
- Implemented verify_external_token() for external IndieAuth providers
- Updated Micropub endpoint to use external verification
- Added TOKEN_ENDPOINT configuration
- Updated all Micropub tests to mock external verification
- HTTP timeout protection (5s) for external requests

Additional Changes:
- Created migration 003 to remove code_verifier from auth_state
- Fixed 5 migration tests that referenced obsolete code_verifier column
- Updated 11 Micropub tests for external verification
- Fixed test fixture and app context issues
- All 501 tests passing

Breaking Changes:
- Micropub clients must use external IndieAuth providers
- TOKEN_ENDPOINT configuration now required
- Existing internal tokens invalid (tables dropped)

Migration Impact:
- Simpler codebase: -500 lines of code
- Fewer database tables: -2 tables (tokens, authorization_codes)
- More secure: External providers handle token security
- More maintainable: Less authentication code to maintain

Standards Compliance:
- W3C IndieAuth specification
- OAuth 2.0 Bearer token authentication
- IndieWeb principle: delegate to external services

Related:
- ADR-030: IndieAuth Provider Removal Strategy
- ADR-050: Remove Custom IndieAuth Server
- Migration 003: Remove code_verifier from auth_state
- Migration 004: Drop tokens and authorization_codes tables

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 17:23:46 -07:00
parent 869402ab0d
commit a3bac86647
36 changed files with 5597 additions and 2670 deletions

View File

@@ -277,156 +277,7 @@ class TestVersionDisplay:
assert b"0.5.0" in response.data or b"StarPunk v" in response.data
class TestOAuthMetadataEndpoint:
"""Test OAuth Client ID Metadata Document endpoint (.well-known/oauth-authorization-server)"""
def test_oauth_metadata_endpoint_exists(self, client):
"""Verify metadata endpoint returns 200 OK"""
response = client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
def test_oauth_metadata_content_type(self, client):
"""Verify response is JSON with correct content type"""
response = client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
assert response.content_type == "application/json"
def test_oauth_metadata_required_fields(self, client, app):
"""Verify all required fields are present and valid"""
response = client.get("/.well-known/oauth-authorization-server")
data = response.get_json()
# Required fields per IndieAuth spec
assert "client_id" in data
assert "client_name" in data
assert "redirect_uris" in data
# client_id must match SITE_URL exactly (spec requirement)
with app.app_context():
assert data["client_id"] == app.config["SITE_URL"]
# redirect_uris must be array
assert isinstance(data["redirect_uris"], list)
assert len(data["redirect_uris"]) > 0
def test_oauth_metadata_optional_fields(self, client):
"""Verify recommended optional fields are present"""
response = client.get("/.well-known/oauth-authorization-server")
data = response.get_json()
# Recommended fields
assert "issuer" in data
assert "client_uri" in data
assert "grant_types_supported" in data
assert "response_types_supported" in data
assert "code_challenge_methods_supported" in data
assert "token_endpoint_auth_methods_supported" in data
def test_oauth_metadata_field_values(self, client, app):
"""Verify field values are correct"""
response = client.get("/.well-known/oauth-authorization-server")
data = response.get_json()
with app.app_context():
site_url = app.config["SITE_URL"]
# Verify URLs
assert data["issuer"] == site_url
assert data["client_id"] == site_url
assert data["client_uri"] == site_url
# Verify redirect_uris contains auth callback
assert f"{site_url}/auth/callback" in data["redirect_uris"]
# Verify supported methods
assert "authorization_code" in data["grant_types_supported"]
assert "code" in data["response_types_supported"]
assert "S256" in data["code_challenge_methods_supported"]
assert "none" in data["token_endpoint_auth_methods_supported"]
def test_oauth_metadata_redirect_uris_is_array(self, client):
"""Verify redirect_uris is array, not string (common pitfall)"""
response = client.get("/.well-known/oauth-authorization-server")
data = response.get_json()
assert isinstance(data["redirect_uris"], list)
assert not isinstance(data["redirect_uris"], str)
def test_oauth_metadata_cache_headers(self, client):
"""Verify appropriate cache headers are set"""
response = client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
# Should cache for 24 hours (86400 seconds)
assert response.cache_control.max_age == 86400
assert response.cache_control.public is True
def test_oauth_metadata_valid_json(self, client):
"""Verify response is valid, parseable JSON"""
response = client.get("/.well-known/oauth-authorization-server")
assert response.status_code == 200
# get_json() will raise ValueError if JSON is invalid
data = response.get_json()
assert data is not None
assert isinstance(data, dict)
def test_oauth_metadata_uses_config_values(self, tmp_path):
"""Verify metadata uses config values, not hardcoded strings"""
test_data_dir = tmp_path / "oauth_test"
test_data_dir.mkdir(parents=True, exist_ok=True)
# Create app with custom config
test_config = {
"TESTING": True,
"DATABASE_PATH": test_data_dir / "starpunk.db",
"DATA_PATH": test_data_dir,
"NOTES_PATH": test_data_dir / "notes",
"SESSION_SECRET": "test-secret",
"SITE_URL": "https://custom-site.example.com",
"SITE_NAME": "Custom Site Name",
"DEV_MODE": False,
}
app = create_app(config=test_config)
client = app.test_client()
response = client.get("/.well-known/oauth-authorization-server")
data = response.get_json()
# Should use custom config values
assert data["client_id"] == "https://custom-site.example.com"
assert data["client_name"] == "Custom Site Name"
assert data["client_uri"] == "https://custom-site.example.com"
assert (
"https://custom-site.example.com/auth/callback" in data["redirect_uris"]
)
class TestIndieAuthMetadataLink:
"""Test indieauth-metadata link in HTML head"""
def test_indieauth_metadata_link_present(self, client):
"""Verify discovery link is present in HTML head"""
response = client.get("/")
assert response.status_code == 200
assert b'rel="indieauth-metadata"' in response.data
def test_indieauth_metadata_link_points_to_endpoint(self, client):
"""Verify link points to correct endpoint"""
response = client.get("/")
assert response.status_code == 200
assert b"/.well-known/oauth-authorization-server" in response.data
def test_indieauth_metadata_link_in_head(self, client):
"""Verify link is in <head> section"""
response = client.get("/")
assert response.status_code == 200
# Simple check: link should appear before <body>
html = response.data.decode("utf-8")
metadata_link_pos = html.find('rel="indieauth-metadata"')
body_pos = html.find("<body>")
assert metadata_link_pos != -1
assert body_pos != -1
assert metadata_link_pos < body_pos
# OAuth metadata endpoint tests removed in Phase 1 of IndieAuth server removal
# The /.well-known/oauth-authorization-server endpoint was removed as part of
# removing the built-in IndieAuth authorization server functionality.
# See: docs/architecture/indieauth-removal-phases.md