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