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:
@@ -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"}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user