feat: Add detailed IndieAuth logging with security-aware token redaction

- Add logging helper functions with automatic token redaction
- Implement comprehensive logging throughout auth flow
- Add production warning for DEBUG logging
- Add 14 new tests for logging functionality
- Update version to v0.7.0

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-19 14:51:30 -07:00
parent 8be079593f
commit 01e66a063e
9 changed files with 2887 additions and 13 deletions

View File

@@ -19,6 +19,9 @@ from starpunk.auth import (
_cleanup_expired_sessions,
_generate_state_token,
_hash_token,
_log_http_request,
_log_http_response,
_redact_token,
_verify_state_token,
create_session,
destroy_session,
@@ -646,3 +649,237 @@ class TestExceptionHierarchy:
error = IndieLoginError("Service error")
assert str(error) == "Service error"
class TestLoggingHelpers:
def test_redact_token_normal(self):
"""Test token redaction for normal-length tokens"""
token = "abcdefghijklmnopqrstuvwxyz"
result = _redact_token(token, 6)
assert result == "abcdef...********...wxyz"
def test_redact_token_short(self):
"""Test token redaction for short tokens"""
token = "short"
result = _redact_token(token, 6)
assert result == "***REDACTED***"
def test_redact_token_empty(self):
"""Test token redaction for empty tokens"""
result = _redact_token("", 6)
assert result == "***REDACTED***"
result = _redact_token(None, 6)
assert result == "***REDACTED***"
def test_redact_token_custom_length(self):
"""Test token redaction with custom show_chars"""
token = "abcdefghijklmnopqrstuvwxyz"
result = _redact_token(token, 8)
assert result == "abcdefgh...********...wxyz"
def test_log_http_request_redacts_code(self, app, caplog):
"""Test that code parameter is redacted in request logs"""
import logging
with app.app_context():
# Set DEBUG level for logging
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
_log_http_request(
method="POST",
url="https://indielogin.com/auth",
data={"code": "sensitive_code_12345"},
)
# Should log but with redacted code
assert "sensitive_code_12345" not in caplog.text
assert "sensit...********...2345" in caplog.text
def test_log_http_request_redacts_state(self, app, caplog):
"""Test that state parameter is redacted in request logs"""
import logging
with app.app_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
_log_http_request(
method="POST",
url="https://indielogin.com/auth",
data={"state": "state_token_123456789"},
)
# Should log but with redacted state (8 chars shown at start)
assert "state_token_123456789" not in caplog.text
assert "state_to...********...6789" in caplog.text
def test_log_http_request_not_logged_at_info(self, app, caplog):
"""Test that HTTP requests are not logged at INFO level"""
import logging
with app.app_context():
app.logger.setLevel(logging.INFO)
with caplog.at_level(logging.INFO):
_log_http_request(
method="POST",
url="https://indielogin.com/auth",
data={"code": "test_code"},
)
# Should not log anything
assert "IndieAuth HTTP Request" not in caplog.text
def test_log_http_response_redacts_tokens(self, app, caplog):
"""Test that response tokens are redacted"""
import logging
with app.app_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
_log_http_response(
status_code=200,
headers={"content-type": "application/json"},
body='{"access_token": "secret_token_xyz789"}',
)
# Should log but with redacted token
assert "secret_token_xyz789" not in caplog.text
assert "secret...********...z789" in caplog.text
def test_log_http_response_handles_non_json(self, app, caplog):
"""Test that non-JSON responses are logged as-is"""
import logging
with app.app_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
_log_http_response(
status_code=500, headers={}, body="Internal Server Error"
)
# Should log the plain text body
assert "Internal Server Error" in caplog.text
def test_log_http_response_redacts_sensitive_headers(self, app, caplog):
"""Test that sensitive headers are redacted"""
import logging
with app.app_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
_log_http_response(
status_code=200,
headers={
"content-type": "application/json",
"set-cookie": "sensitive_cookie",
"authorization": "Bearer token",
},
body='{"me": "https://example.com"}',
)
# Should log content-type but not sensitive headers
assert "content-type" in caplog.text
assert "set-cookie" not in caplog.text
assert "authorization" not in caplog.text
assert "sensitive_cookie" not in caplog.text
class TestLoggingIntegration:
def test_initiate_login_logs_at_debug(self, app, db, caplog):
"""Test that initiate_login logs at DEBUG level"""
import logging
with app.app_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
me_url = "https://example.com"
initiate_login(me_url)
# Should see DEBUG logs
assert "Validating me URL" in caplog.text
assert "Generated state token" in caplog.text
assert "Building authorization URL" in caplog.text
# Should see INFO log
assert "Authentication initiated" in caplog.text
def test_initiate_login_info_level(self, app, db, caplog):
"""Test that initiate_login only shows milestones at INFO level"""
import logging
with app.app_context():
app.logger.setLevel(logging.INFO)
with caplog.at_level(logging.INFO):
me_url = "https://example.com"
initiate_login(me_url)
# Should see INFO milestone
assert "Authentication initiated" in caplog.text
# Should NOT see DEBUG details
assert "Validating me URL" not in caplog.text
assert "Generated state token" not in caplog.text
@patch("starpunk.auth.httpx.post")
def test_handle_callback_logs_http_details(self, mock_post, app, db, client, caplog):
"""Test that handle_callback logs HTTP request/response at DEBUG"""
import logging
with app.test_request_context():
app.logger.setLevel(logging.DEBUG)
# Setup state token
state = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(minutes=5)
db.execute(
"INSERT INTO auth_state (state, expires_at) VALUES (?, ?)",
(state, expires_at),
)
db.commit()
# Mock IndieLogin response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.text = '{"me": "https://example.com"}'
mock_response.json.return_value = {"me": "https://example.com"}
mock_post.return_value = mock_response
with caplog.at_level(logging.DEBUG):
code = "test_authorization_code"
handle_callback(code, state)
# Should see HTTP request/response logs
assert "IndieAuth HTTP Request" in caplog.text
assert "IndieAuth HTTP Response" in caplog.text
# Code should be redacted
assert "test_authorization_code" not in caplog.text
assert "test_a...********...code" in caplog.text
def test_create_session_logs_details(self, app, db, client, caplog):
"""Test that create_session logs session details at DEBUG"""
import logging
with app.test_request_context():
app.logger.setLevel(logging.DEBUG)
with caplog.at_level(logging.DEBUG):
me = "https://example.com"
create_session(me)
# Should see DEBUG logs
assert "Session token generated" in caplog.text
assert "Session expiry" in caplog.text
assert "Request metadata" in caplog.text
# Should see INFO log
assert "Session created" in caplog.text