Files
StarPunk/tests/test_auth_external.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

638 lines
20 KiB
Python

"""
Tests for external IndieAuth token verification with endpoint discovery
Tests cover:
- Endpoint discovery from HTTP Link headers
- Endpoint discovery from HTML link elements
- Token verification with discovered endpoints
- Caching behavior for endpoints and tokens
- Error handling and edge cases
- HTTPS validation
- URL normalization
ADR: ADR-031 IndieAuth Endpoint Discovery Implementation
"""
import hashlib
import time
from unittest.mock import Mock, patch
import pytest
import httpx
from starpunk.auth_external import (
verify_external_token,
discover_endpoints,
check_scope,
normalize_url,
_parse_link_header,
_parse_html_links,
_cache,
DiscoveryError,
TokenVerificationError,
ENDPOINT_CACHE_TTL,
TOKEN_CACHE_TTL,
)
# Test Fixtures
# -------------
@pytest.fixture
def mock_profile_html():
"""HTML profile with IndieAuth link elements"""
return """
<!DOCTYPE html>
<html>
<head>
<link rel="authorization_endpoint" href="https://auth.example.com/authorize">
<link rel="token_endpoint" href="https://auth.example.com/token">
<title>Test Profile</title>
</head>
<body>
<h1>Hello World</h1>
</body>
</html>
"""
@pytest.fixture
def mock_profile_html_relative():
"""HTML profile with relative URLs"""
return """
<!DOCTYPE html>
<html>
<head>
<link rel="authorization_endpoint" href="/auth/authorize">
<link rel="token_endpoint" href="/auth/token">
</head>
<body></body>
</html>
"""
@pytest.fixture
def mock_link_headers():
"""HTTP Link headers with IndieAuth endpoints"""
return (
'<https://auth.example.com/authorize>; rel="authorization_endpoint", '
'<https://auth.example.com/token>; rel="token_endpoint"'
)
@pytest.fixture
def mock_token_response():
"""Valid token verification response"""
return {
'me': 'https://alice.example.com/',
'client_id': 'https://app.example.com/',
'scope': 'create update',
}
@pytest.fixture(autouse=True)
def clear_cache():
"""Clear cache before each test"""
_cache.endpoints = None
_cache.endpoints_expire = 0
_cache.token_cache.clear()
yield
# Clear after test too
_cache.endpoints = None
_cache.endpoints_expire = 0
_cache.token_cache.clear()
# Endpoint Discovery Tests
# -------------------------
def test_parse_link_header_both_endpoints(mock_link_headers):
"""Parse Link header with both authorization and token endpoints"""
endpoints = _parse_link_header(mock_link_headers, 'https://alice.example.com/')
assert endpoints['authorization_endpoint'] == 'https://auth.example.com/authorize'
assert endpoints['token_endpoint'] == 'https://auth.example.com/token'
def test_parse_link_header_single_endpoint():
"""Parse Link header with only token endpoint"""
header = '<https://auth.example.com/token>; rel="token_endpoint"'
endpoints = _parse_link_header(header, 'https://alice.example.com/')
assert endpoints['token_endpoint'] == 'https://auth.example.com/token'
assert 'authorization_endpoint' not in endpoints
def test_parse_link_header_relative_url():
"""Parse Link header with relative URL"""
header = '</auth/token>; rel="token_endpoint"'
endpoints = _parse_link_header(header, 'https://alice.example.com/')
assert endpoints['token_endpoint'] == 'https://alice.example.com/auth/token'
def test_parse_html_links_both_endpoints(mock_profile_html):
"""Parse HTML with both authorization and token endpoints"""
endpoints = _parse_html_links(mock_profile_html, 'https://alice.example.com/')
assert endpoints['authorization_endpoint'] == 'https://auth.example.com/authorize'
assert endpoints['token_endpoint'] == 'https://auth.example.com/token'
def test_parse_html_links_relative_urls(mock_profile_html_relative):
"""Parse HTML with relative endpoint URLs"""
endpoints = _parse_html_links(
mock_profile_html_relative,
'https://alice.example.com/'
)
assert endpoints['authorization_endpoint'] == 'https://alice.example.com/auth/authorize'
assert endpoints['token_endpoint'] == 'https://alice.example.com/auth/token'
def test_parse_html_links_empty():
"""Parse HTML with no IndieAuth links"""
html = '<html><head></head><body></body></html>'
endpoints = _parse_html_links(html, 'https://alice.example.com/')
assert endpoints == {}
def test_parse_html_links_malformed():
"""Parse malformed HTML gracefully"""
html = '<html><head><link rel="token_endpoint"' # Missing closing tags
endpoints = _parse_html_links(html, 'https://alice.example.com/')
# Should return empty dict, not crash
assert isinstance(endpoints, dict)
def test_parse_html_links_rel_as_list():
"""Parse HTML where rel attribute is a list"""
html = '''
<html><head>
<link rel="authorization_endpoint me" href="https://auth.example.com/authorize">
</head></html>
'''
endpoints = _parse_html_links(html, 'https://alice.example.com/')
assert endpoints['authorization_endpoint'] == 'https://auth.example.com/authorize'
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_from_html(mock_get, app_with_admin_me, mock_profile_html):
"""Discover endpoints from HTML link elements"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
mock_response.text = mock_profile_html
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
endpoints = discover_endpoints('https://alice.example.com/')
assert endpoints['token_endpoint'] == 'https://auth.example.com/token'
assert endpoints['authorization_endpoint'] == 'https://auth.example.com/authorize'
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_from_link_header(mock_get, app_with_admin_me, mock_link_headers):
"""Discover endpoints from HTTP Link headers"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {
'Content-Type': 'text/html',
'Link': mock_link_headers
}
mock_response.text = '<html></html>'
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
endpoints = discover_endpoints('https://alice.example.com/')
assert endpoints['token_endpoint'] == 'https://auth.example.com/token'
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_link_header_priority(mock_get, app_with_admin_me, mock_profile_html, mock_link_headers):
"""Link headers take priority over HTML link elements"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {
'Content-Type': 'text/html',
'Link': '<https://different.example.com/token>; rel="token_endpoint"'
}
# HTML has different endpoint
mock_response.text = mock_profile_html
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
endpoints = discover_endpoints('https://alice.example.com/')
# Link header should win
assert endpoints['token_endpoint'] == 'https://different.example.com/token'
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_no_token_endpoint(mock_get, app_with_admin_me):
"""Raise error if no token endpoint found"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
mock_response.text = '<html><head></head><body></body></html>'
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://alice.example.com/')
assert 'No token endpoint found' in str(exc_info.value)
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_http_error(mock_get, app_with_admin_me):
"""Handle HTTP errors during discovery"""
mock_get.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=Mock(),
response=Mock(status_code=404)
)
with app_with_admin_me.app_context():
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://alice.example.com/')
assert 'HTTP 404' in str(exc_info.value)
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_timeout(mock_get, app_with_admin_me):
"""Handle timeout during discovery"""
mock_get.side_effect = httpx.TimeoutException("Timeout")
with app_with_admin_me.app_context():
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://alice.example.com/')
assert 'Timeout' in str(exc_info.value)
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_network_error(mock_get, app_with_admin_me):
"""Handle network errors during discovery"""
mock_get.side_effect = httpx.NetworkError("Connection failed")
with app_with_admin_me.app_context():
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://alice.example.com/')
assert 'Network error' in str(exc_info.value)
# HTTPS Validation Tests
# -----------------------
def test_discover_endpoints_http_not_allowed_production(app_with_admin_me):
"""HTTP profile URLs not allowed in production"""
with app_with_admin_me.app_context():
app_with_admin_me.config['DEBUG'] = False
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('http://alice.example.com/')
assert 'HTTPS required' in str(exc_info.value)
def test_discover_endpoints_http_allowed_debug(app_with_admin_me):
"""HTTP profile URLs allowed in debug mode"""
with app_with_admin_me.app_context():
app_with_admin_me.config['DEBUG'] = True
# Should validate without raising (mock would be needed for full test)
# Just test validation doesn't raise
from starpunk.auth_external import _validate_profile_url
_validate_profile_url('http://localhost:5000/')
def test_discover_endpoints_localhost_not_allowed_production(app_with_admin_me):
"""Localhost URLs not allowed in production"""
with app_with_admin_me.app_context():
app_with_admin_me.config['DEBUG'] = False
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://localhost/')
assert 'Localhost' in str(exc_info.value)
# Caching Tests
# -------------
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_caching(mock_get, app_with_admin_me, mock_profile_html):
"""Discovered endpoints are cached"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
mock_response.text = mock_profile_html
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
# First call - should fetch
endpoints1 = discover_endpoints('https://alice.example.com/')
# Second call - should use cache
endpoints2 = discover_endpoints('https://alice.example.com/')
# Should only call httpx.get once
assert mock_get.call_count == 1
assert endpoints1 == endpoints2
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_cache_expiry(mock_get, app_with_admin_me, mock_profile_html):
"""Endpoint cache expires after TTL"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
mock_response.text = mock_profile_html
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
# First call
discover_endpoints('https://alice.example.com/')
# Expire cache manually
_cache.endpoints_expire = time.time() - 1
# Second call should fetch again
discover_endpoints('https://alice.example.com/')
assert mock_get.call_count == 2
@patch('starpunk.auth_external.httpx.get')
def test_discover_endpoints_grace_period(mock_get, app_with_admin_me, mock_profile_html):
"""Use expired cache on network failure (grace period)"""
# First call succeeds
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
mock_response.text = mock_profile_html
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
endpoints1 = discover_endpoints('https://alice.example.com/')
# Expire cache
_cache.endpoints_expire = time.time() - 1
# Second call fails, but should use expired cache
mock_get.side_effect = httpx.NetworkError("Connection failed")
endpoints2 = discover_endpoints('https://alice.example.com/')
# Should return cached endpoints despite network failure
assert endpoints1 == endpoints2
# Token Verification Tests
# -------------------------
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_success(mock_get, mock_discover, app_with_admin_me, mock_token_response):
"""Successfully verify token with discovered endpoint"""
# Mock discovery
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
# Mock token verification
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_token_response
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
token_info = verify_external_token('test-token-123')
assert token_info is not None
assert token_info['me'] == 'https://alice.example.com/'
assert token_info['scope'] == 'create update'
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_wrong_me(mock_get, mock_discover, app_with_admin_me):
"""Reject token for different user"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
# Token for wrong user
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'me': 'https://bob.example.com/', # Not ADMIN_ME
'scope': 'create',
}
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
token_info = verify_external_token('test-token-123')
# Should reject
assert token_info is None
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_401(mock_get, mock_discover, app_with_admin_me):
"""Handle 401 Unauthorized from token endpoint"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
mock_response = Mock()
mock_response.status_code = 401
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
token_info = verify_external_token('invalid-token')
assert token_info is None
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_missing_me(mock_get, mock_discover, app_with_admin_me):
"""Reject token response missing 'me' field"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
'scope': 'create',
# Missing 'me' field
}
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
token_info = verify_external_token('test-token')
assert token_info is None
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_retry_on_500(mock_get, mock_discover, app_with_admin_me, mock_token_response):
"""Retry token verification on 500 server error"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
# First call: 500 error
error_response = Mock()
error_response.status_code = 500
# Second call: success
success_response = Mock()
success_response.status_code = 200
success_response.json.return_value = mock_token_response
mock_get.side_effect = [error_response, success_response]
with app_with_admin_me.app_context():
with patch('time.sleep'): # Skip sleep delay
token_info = verify_external_token('test-token')
assert token_info is not None
assert mock_get.call_count == 2
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_no_retry_on_403(mock_get, mock_discover, app_with_admin_me):
"""Don't retry on 403 Forbidden (client error)"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
mock_response = Mock()
mock_response.status_code = 403
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
token_info = verify_external_token('test-token')
assert token_info is None
# Should only call once (no retries)
assert mock_get.call_count == 1
@patch('starpunk.auth_external.discover_endpoints')
@patch('starpunk.auth_external.httpx.get')
def test_verify_external_token_caching(mock_get, mock_discover, app_with_admin_me, mock_token_response):
"""Token verifications are cached"""
mock_discover.return_value = {
'token_endpoint': 'https://auth.example.com/token'
}
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_token_response
mock_get.return_value = mock_response
with app_with_admin_me.app_context():
# First call
token_info1 = verify_external_token('test-token')
# Second call should use cache
token_info2 = verify_external_token('test-token')
assert token_info1 == token_info2
# Should only verify once
assert mock_get.call_count == 1
@patch('starpunk.auth_external.discover_endpoints')
def test_verify_external_token_no_admin_me(mock_discover, app):
"""Fail if ADMIN_ME not configured"""
with app.app_context():
# app fixture has no ADMIN_ME
token_info = verify_external_token('test-token')
assert token_info is None
# Should not even attempt discovery
mock_discover.assert_not_called()
# URL Normalization Tests
# ------------------------
def test_normalize_url_removes_trailing_slash():
"""Normalize URL removes trailing slash"""
assert normalize_url('https://example.com/') == 'https://example.com'
assert normalize_url('https://example.com') == 'https://example.com'
def test_normalize_url_lowercase():
"""Normalize URL converts to lowercase"""
assert normalize_url('https://Example.COM/') == 'https://example.com'
assert normalize_url('HTTPS://EXAMPLE.COM') == 'https://example.com'
def test_normalize_url_path_preserved():
"""Normalize URL preserves path"""
assert normalize_url('https://example.com/path/') == 'https://example.com/path'
assert normalize_url('https://Example.com/Path') == 'https://example.com/path'
# Scope Checking Tests
# ---------------------
def test_check_scope_present():
"""Check scope returns True when scope is present"""
assert check_scope('create', 'create update delete') is True
assert check_scope('create', 'create') is True
def test_check_scope_missing():
"""Check scope returns False when scope is missing"""
assert check_scope('create', 'update delete') is False
assert check_scope('create', '') is False
assert check_scope('create', 'created') is False # Partial match
def test_check_scope_empty():
"""Check scope handles empty scope string"""
assert check_scope('create', '') is False
assert check_scope('create', None) is False
# Fixtures
# --------
@pytest.fixture
def app():
"""Create test Flask app without ADMIN_ME"""
from flask import Flask
app = Flask(__name__)
app.config['TESTING'] = True
app.config['DEBUG'] = False
return app
@pytest.fixture
def app_with_admin_me():
"""Create test Flask app with ADMIN_ME configured"""
from flask import Flask
app = Flask(__name__)
app.config['TESTING'] = True
app.config['DEBUG'] = False
app.config['ADMIN_ME'] = 'https://alice.example.com/'
app.config['VERSION'] = '1.0.0-test'
return app