fix(auth): Implement IndieAuth endpoint discovery per W3C spec

BREAKING: Removes INDIELOGIN_URL config - endpoints are now properly
discovered from user's profile URL as required by W3C IndieAuth spec.

- auth.py: Uses discover_endpoints() to find authorization_endpoint
- config.py: Deprecation warning for obsolete INDIELOGIN_URL setting
- auth_external.py: Relaxed validation (allows auth-only flows)
- tests: Updated to mock endpoint discovery

This fixes a regression where admin login was hardcoded to use
indielogin.com instead of respecting the user's declared endpoints.

Version: 1.5.0-hotfix.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-17 13:52:36 -07:00
parent 84e693fe57
commit 2bd971f3d6
12 changed files with 1366 additions and 77 deletions

View File

@@ -48,7 +48,6 @@ def app(tmp_path):
"ADMIN_ME": "https://example.com",
"SESSION_SECRET": secrets.token_hex(32),
"SESSION_LIFETIME": 30,
"INDIELOGIN_URL": "https://indielogin.com",
"DATA_PATH": test_data_dir,
"NOTES_PATH": test_data_dir / "notes",
"DATABASE_PATH": test_data_dir / "starpunk.db",
@@ -216,13 +215,20 @@ class TestCleanup:
class TestInitiateLogin:
def test_initiate_login_success(self, app, db):
@patch("starpunk.auth.discover_endpoints")
def test_initiate_login_success(self, mock_discover, app, db):
"""Test successful login initiation"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
me_url = "https://example.com"
auth_url = initiate_login(me_url)
assert "indielogin.com/auth" in auth_url
# Changed: Check for discovered endpoint instead of indielogin.com
assert "auth.example.com/authorize" in auth_url
assert "me=https%3A%2F%2Fexample.com" in auth_url
assert "client_id=" in auth_url
assert "redirect_uri=" in auth_url
@@ -239,8 +245,14 @@ class TestInitiateLogin:
with pytest.raises(ValueError, match="Invalid URL format"):
initiate_login("not-a-url")
def test_initiate_login_stores_state(self, app, db):
@patch("starpunk.auth.discover_endpoints")
def test_initiate_login_stores_state(self, mock_discover, app, db):
"""Test that state token is stored"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
me_url = "https://example.com"
auth_url = initiate_login(me_url)
@@ -257,9 +269,15 @@ class TestInitiateLogin:
class TestHandleCallback:
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_success(self, mock_post, app, db, client):
def test_handle_callback_success(self, mock_post, mock_discover, app, db, client):
"""Test successful callback handling"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.test_request_context():
# Setup state token
state = secrets.token_urlsafe(32)
@@ -270,7 +288,7 @@ class TestHandleCallback:
)
db.commit()
# Mock IndieLogin response
# Mock authorization endpoint response
mock_response = MagicMock()
mock_response.json.return_value = {"me": "https://example.com"}
mock_post.return_value = mock_response
@@ -296,9 +314,15 @@ class TestHandleCallback:
with pytest.raises(InvalidStateError):
handle_callback("code", "invalid-state")
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_unauthorized_user(self, mock_post, app, db):
def test_handle_callback_unauthorized_user(self, mock_post, mock_discover, app, db):
"""Test callback with unauthorized user"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
# Setup state token
state = secrets.token_urlsafe(32)
@@ -309,7 +333,7 @@ class TestHandleCallback:
)
db.commit()
# Mock IndieLogin response with different user
# Mock authorization endpoint response with different user
mock_response = MagicMock()
mock_response.json.return_value = {"me": "https://attacker.com"}
mock_post.return_value = mock_response
@@ -317,9 +341,15 @@ class TestHandleCallback:
with pytest.raises(UnauthorizedError):
handle_callback("code", state)
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_indielogin_error(self, mock_post, app, db):
"""Test callback with IndieLogin error"""
def test_handle_callback_indielogin_error(self, mock_post, mock_discover, app, db):
"""Test callback with authorization endpoint error"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
# Setup state token
state = secrets.token_urlsafe(32)
@@ -330,15 +360,21 @@ class TestHandleCallback:
)
db.commit()
# Mock IndieLogin error
# Mock authorization endpoint error
mock_post.side_effect = httpx.RequestError("Connection failed")
with pytest.raises(IndieLoginError):
handle_callback("code", state)
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_no_identity(self, mock_post, app, db):
def test_handle_callback_no_identity(self, mock_post, mock_discover, app, db):
"""Test callback with no identity in response"""
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
# Setup state token
state = secrets.token_urlsafe(32)
@@ -349,7 +385,7 @@ class TestHandleCallback:
)
db.commit()
# Mock IndieLogin response without 'me' field
# Mock authorization endpoint response without 'me' field
mock_response = MagicMock()
mock_response.json.return_value = {}
mock_post.return_value = mock_response
@@ -791,10 +827,16 @@ class TestLoggingHelpers:
class TestLoggingIntegration:
def test_initiate_login_logs_at_debug(self, app, db, caplog):
@patch("starpunk.auth.discover_endpoints")
def test_initiate_login_logs_at_debug(self, mock_discover, app, db, caplog):
"""Test that initiate_login logs at DEBUG level"""
import logging
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
app.logger.setLevel(logging.DEBUG)
@@ -810,10 +852,16 @@ class TestLoggingIntegration:
# Should see INFO log
assert "Authentication initiated" in caplog.text
def test_initiate_login_info_level(self, app, db, caplog):
@patch("starpunk.auth.discover_endpoints")
def test_initiate_login_info_level(self, mock_discover, app, db, caplog):
"""Test that initiate_login only shows milestones at INFO level"""
import logging
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.app_context():
app.logger.setLevel(logging.INFO)
@@ -828,11 +876,17 @@ class TestLoggingIntegration:
assert "Validating me URL" not in caplog.text
assert "Generated state token" not in caplog.text
@patch("starpunk.auth.discover_endpoints")
@patch("starpunk.auth.httpx.post")
def test_handle_callback_logs_http_details(self, mock_post, app, db, client, caplog):
def test_handle_callback_logs_http_details(self, mock_post, mock_discover, app, db, client, caplog):
"""Test that handle_callback logs HTTP request/response at DEBUG"""
import logging
mock_discover.return_value = {
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token'
}
with app.test_request_context():
app.logger.setLevel(logging.DEBUG)
@@ -845,7 +899,7 @@ class TestLoggingIntegration:
)
db.commit()
# Mock IndieLogin response
# Mock authorization endpoint response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}

View File

@@ -234,7 +234,7 @@ def test_discover_endpoints_link_header_priority(mock_get, app_with_admin_me, mo
@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"""
"""Raise error if no IndieAuth endpoints found"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.headers = {'Content-Type': 'text/html'}
@@ -245,7 +245,7 @@ def test_discover_endpoints_no_token_endpoint(mock_get, app_with_admin_me):
with pytest.raises(DiscoveryError) as exc_info:
discover_endpoints('https://alice.example.com/')
assert 'No token endpoint found' in str(exc_info.value)
assert 'No IndieAuth endpoints found' in str(exc_info.value)
@patch('starpunk.auth_external.httpx.get')