fix(auth): require email authentication every login
CRITICAL SECURITY FIX: - Email code required EVERY login (authentication, not verification) - DNS TXT check cached separately (domain verification) - New auth_sessions table for per-login state - Codes hashed with SHA-256, constant-time comparison - Max 3 attempts, 10-minute session expiry - OAuth params stored server-side (security improvement) New files: - services/auth_session.py - migrations 004, 005 - ADR-010: domain verification vs user authentication 312 tests passing, 86.21% coverage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,12 @@ Integration tests for authorization endpoint flow.
|
||||
|
||||
Tests the complete authorization endpoint behavior including parameter validation,
|
||||
client metadata fetching, consent form rendering, and code generation.
|
||||
|
||||
Updated for the session-based authentication flow (ADR-010).
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
@@ -184,7 +187,7 @@ class TestAuthorizationEndpointRedirectErrors:
|
||||
|
||||
|
||||
class TestAuthorizationConsentPage:
|
||||
"""Tests for the consent page rendering."""
|
||||
"""Tests for the consent page rendering (after email verification)."""
|
||||
|
||||
@pytest.fixture
|
||||
def complete_params(self):
|
||||
@@ -199,131 +202,279 @@ class TestAuthorizationConsentPage:
|
||||
"me": "https://user.example.com",
|
||||
}
|
||||
|
||||
def test_valid_request_shows_consent_page(self, auth_client, complete_params, mock_happ_fetch):
|
||||
"""Test valid authorization request shows consent page."""
|
||||
response = auth_client.get("/authorize", params=complete_params)
|
||||
def test_valid_request_shows_verification_page(self, auth_app, complete_params, mock_happ_fetch):
|
||||
"""Test valid authorization request shows verification page (not consent directly)."""
|
||||
from gondulf.dependencies import (
|
||||
get_dns_service, get_email_service, get_html_fetcher,
|
||||
get_relme_parser, get_auth_session_service, get_database
|
||||
)
|
||||
from gondulf.database.connection import Database
|
||||
from sqlalchemy import text
|
||||
import tempfile
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
# Page should contain client information
|
||||
assert "app.example.com" in response.text or "Test Application" in response.text
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db = Database(f"sqlite:///{db_path}")
|
||||
db.initialize()
|
||||
|
||||
def test_consent_page_contains_required_fields(self, auth_client, complete_params, mock_happ_fetch):
|
||||
"""Test consent page contains all required form fields."""
|
||||
response = auth_client.get("/authorize", params=complete_params)
|
||||
# Setup DNS-verified domain
|
||||
now = datetime.utcnow()
|
||||
with db.get_engine().begin() as conn:
|
||||
conn.execute(
|
||||
text("""
|
||||
INSERT OR REPLACE INTO domains
|
||||
(domain, email, verification_code, verified, verified_at, last_checked, two_factor)
|
||||
VALUES (:domain, '', '', 1, :now, :now, 0)
|
||||
"""),
|
||||
{"domain": "user.example.com", "now": now}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Check for hidden form fields that will be POSTed
|
||||
assert "client_id" in response.text
|
||||
assert "redirect_uri" in response.text
|
||||
assert "code_challenge" in response.text
|
||||
# Create mock services
|
||||
mock_dns = Mock()
|
||||
mock_dns.verify_txt_record.return_value = True
|
||||
|
||||
def test_consent_page_displays_client_metadata(self, auth_client, complete_params, mock_happ_fetch):
|
||||
"""Test consent page displays client h-app metadata."""
|
||||
response = auth_client.get("/authorize", params=complete_params)
|
||||
mock_email = Mock()
|
||||
mock_email.send_verification_code = Mock()
|
||||
|
||||
assert response.status_code == 200
|
||||
# Should show client name from h-app
|
||||
assert "Test Application" in response.text or "app.example.com" in response.text
|
||||
mock_html = Mock()
|
||||
mock_html.fetch.return_value = '<html><a href="mailto:test@example.com" rel="me">Email</a></html>'
|
||||
|
||||
def test_consent_page_preserves_state(self, auth_client, complete_params, mock_happ_fetch):
|
||||
"""Test consent page preserves state parameter."""
|
||||
response = auth_client.get("/authorize", params=complete_params)
|
||||
from gondulf.services.relme_parser import RelMeParser
|
||||
mock_relme = RelMeParser()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "test123" in response.text
|
||||
mock_session = Mock()
|
||||
mock_session.create_session.return_value = {
|
||||
"session_id": "test_session_123",
|
||||
"verification_code": "123456",
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=10)
|
||||
}
|
||||
|
||||
auth_app.dependency_overrides[get_database] = lambda: db
|
||||
auth_app.dependency_overrides[get_dns_service] = lambda: mock_dns
|
||||
auth_app.dependency_overrides[get_email_service] = lambda: mock_email
|
||||
auth_app.dependency_overrides[get_html_fetcher] = lambda: mock_html
|
||||
auth_app.dependency_overrides[get_relme_parser] = lambda: mock_relme
|
||||
auth_app.dependency_overrides[get_auth_session_service] = lambda: mock_session
|
||||
|
||||
try:
|
||||
with TestClient(auth_app) as client:
|
||||
response = client.get("/authorize", params=complete_params)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
# Should show verification page (email auth required every login)
|
||||
assert "Verify Your Identity" in response.text
|
||||
finally:
|
||||
auth_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestAuthorizationConsentSubmission:
|
||||
"""Tests for consent form submission."""
|
||||
"""Tests for consent form submission (via session-based flow)."""
|
||||
|
||||
@pytest.fixture
|
||||
def consent_form_data(self):
|
||||
"""Valid consent form data."""
|
||||
return {
|
||||
def test_consent_submission_redirects_with_code(self, auth_app):
|
||||
"""Test consent submission redirects to client with authorization code."""
|
||||
from gondulf.dependencies import get_auth_session_service, get_code_storage
|
||||
|
||||
# Mock verified session
|
||||
mock_session = Mock()
|
||||
mock_session.get_session.return_value = {
|
||||
"session_id": "test_session_123",
|
||||
"me": "https://user.example.com",
|
||||
"email": "test@example.com",
|
||||
"code_verified": True, # Session is verified
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "test123",
|
||||
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
"code_challenge_method": "S256",
|
||||
"me": "https://user.example.com",
|
||||
"scope": "",
|
||||
"response_type": "code"
|
||||
}
|
||||
mock_session.delete_session = Mock()
|
||||
|
||||
def test_consent_submission_redirects_with_code(self, auth_client, consent_form_data):
|
||||
"""Test consent submission redirects to client with authorization code."""
|
||||
response = auth_client.post(
|
||||
"/authorize/consent",
|
||||
data=consent_form_data,
|
||||
follow_redirects=False
|
||||
)
|
||||
auth_app.dependency_overrides[get_auth_session_service] = lambda: mock_session
|
||||
|
||||
assert response.status_code == 302
|
||||
location = response.headers["location"]
|
||||
assert location.startswith("https://app.example.com/callback")
|
||||
assert "code=" in location
|
||||
assert "state=test123" in location
|
||||
try:
|
||||
with TestClient(auth_app) as client:
|
||||
response = client.post(
|
||||
"/authorize/consent",
|
||||
data={"session_id": "test_session_123"},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
def test_consent_submission_generates_unique_codes(self, auth_client, consent_form_data):
|
||||
assert response.status_code == 302
|
||||
location = response.headers["location"]
|
||||
assert location.startswith("https://app.example.com/callback")
|
||||
assert "code=" in location
|
||||
assert "state=test123" in location
|
||||
finally:
|
||||
auth_app.dependency_overrides.clear()
|
||||
|
||||
def test_consent_submission_generates_unique_codes(self, auth_app):
|
||||
"""Test each consent generates a unique authorization code."""
|
||||
# First submission
|
||||
response1 = auth_client.post(
|
||||
"/authorize/consent",
|
||||
data=consent_form_data,
|
||||
follow_redirects=False
|
||||
)
|
||||
location1 = response1.headers["location"]
|
||||
from gondulf.dependencies import get_auth_session_service
|
||||
|
||||
# Second submission
|
||||
response2 = auth_client.post(
|
||||
"/authorize/consent",
|
||||
data=consent_form_data,
|
||||
follow_redirects=False
|
||||
)
|
||||
location2 = response2.headers["location"]
|
||||
# Mock verified session
|
||||
mock_session = Mock()
|
||||
mock_session.get_session.return_value = {
|
||||
"session_id": "test_session_123",
|
||||
"me": "https://user.example.com",
|
||||
"email": "test@example.com",
|
||||
"code_verified": True,
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "test123",
|
||||
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "",
|
||||
"response_type": "code"
|
||||
}
|
||||
mock_session.delete_session = Mock()
|
||||
|
||||
# Extract codes
|
||||
from tests.conftest import extract_code_from_redirect
|
||||
code1 = extract_code_from_redirect(location1)
|
||||
code2 = extract_code_from_redirect(location2)
|
||||
auth_app.dependency_overrides[get_auth_session_service] = lambda: mock_session
|
||||
|
||||
assert code1 != code2
|
||||
try:
|
||||
with TestClient(auth_app) as client:
|
||||
# First submission
|
||||
response1 = client.post(
|
||||
"/authorize/consent",
|
||||
data={"session_id": "test_session_123"},
|
||||
follow_redirects=False
|
||||
)
|
||||
location1 = response1.headers["location"]
|
||||
|
||||
def test_authorization_code_stored_for_exchange(self, auth_client, consent_form_data):
|
||||
# Second submission
|
||||
response2 = client.post(
|
||||
"/authorize/consent",
|
||||
data={"session_id": "test_session_123"},
|
||||
follow_redirects=False
|
||||
)
|
||||
location2 = response2.headers["location"]
|
||||
|
||||
# Extract codes
|
||||
from tests.conftest import extract_code_from_redirect
|
||||
code1 = extract_code_from_redirect(location1)
|
||||
code2 = extract_code_from_redirect(location2)
|
||||
|
||||
assert code1 != code2
|
||||
finally:
|
||||
auth_app.dependency_overrides.clear()
|
||||
|
||||
def test_authorization_code_stored_for_exchange(self, auth_app):
|
||||
"""Test authorization code is stored for later token exchange."""
|
||||
response = auth_client.post(
|
||||
"/authorize/consent",
|
||||
data=consent_form_data,
|
||||
follow_redirects=False
|
||||
)
|
||||
from gondulf.dependencies import get_auth_session_service
|
||||
|
||||
from tests.conftest import extract_code_from_redirect
|
||||
code = extract_code_from_redirect(response.headers["location"])
|
||||
# Mock verified session
|
||||
mock_session = Mock()
|
||||
mock_session.get_session.return_value = {
|
||||
"session_id": "test_session_123",
|
||||
"me": "https://user.example.com",
|
||||
"email": "test@example.com",
|
||||
"code_verified": True,
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"state": "test123",
|
||||
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
"code_challenge_method": "S256",
|
||||
"scope": "",
|
||||
"response_type": "code"
|
||||
}
|
||||
mock_session.delete_session = Mock()
|
||||
|
||||
# Code should be non-empty and URL-safe
|
||||
assert code is not None
|
||||
assert len(code) > 20 # Should be a substantial code
|
||||
auth_app.dependency_overrides[get_auth_session_service] = lambda: mock_session
|
||||
|
||||
try:
|
||||
with TestClient(auth_app) as client:
|
||||
response = client.post(
|
||||
"/authorize/consent",
|
||||
data={"session_id": "test_session_123"},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
from tests.conftest import extract_code_from_redirect
|
||||
code = extract_code_from_redirect(response.headers["location"])
|
||||
|
||||
# Code should be non-empty and URL-safe
|
||||
assert code is not None
|
||||
assert len(code) > 20 # Should be a substantial code
|
||||
finally:
|
||||
auth_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestAuthorizationSecurityHeaders:
|
||||
"""Tests for security headers on authorization endpoints."""
|
||||
|
||||
def test_authorization_page_has_security_headers(self, auth_client, mock_happ_fetch):
|
||||
def test_authorization_page_has_security_headers(self, auth_app, mock_happ_fetch):
|
||||
"""Test authorization page includes security headers."""
|
||||
params = {
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"response_type": "code",
|
||||
"state": "test123",
|
||||
"code_challenge": "abc123",
|
||||
"code_challenge_method": "S256",
|
||||
"me": "https://user.example.com",
|
||||
}
|
||||
response = auth_client.get("/authorize", params=params)
|
||||
from gondulf.dependencies import (
|
||||
get_dns_service, get_email_service, get_html_fetcher,
|
||||
get_relme_parser, get_auth_session_service, get_database
|
||||
)
|
||||
from gondulf.database.connection import Database
|
||||
from sqlalchemy import text
|
||||
import tempfile
|
||||
|
||||
assert "X-Frame-Options" in response.headers
|
||||
assert "X-Content-Type-Options" in response.headers
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
db_path = Path(tmpdir) / "test.db"
|
||||
db = Database(f"sqlite:///{db_path}")
|
||||
db.initialize()
|
||||
|
||||
now = datetime.utcnow()
|
||||
with db.get_engine().begin() as conn:
|
||||
conn.execute(
|
||||
text("""
|
||||
INSERT OR REPLACE INTO domains
|
||||
(domain, email, verification_code, verified, verified_at, last_checked, two_factor)
|
||||
VALUES (:domain, '', '', 1, :now, :now, 0)
|
||||
"""),
|
||||
{"domain": "user.example.com", "now": now}
|
||||
)
|
||||
|
||||
mock_dns = Mock()
|
||||
mock_dns.verify_txt_record.return_value = True
|
||||
|
||||
mock_email = Mock()
|
||||
mock_email.send_verification_code = Mock()
|
||||
|
||||
mock_html = Mock()
|
||||
mock_html.fetch.return_value = '<html><a href="mailto:test@example.com" rel="me">Email</a></html>'
|
||||
|
||||
from gondulf.services.relme_parser import RelMeParser
|
||||
mock_relme = RelMeParser()
|
||||
|
||||
mock_session = Mock()
|
||||
mock_session.create_session.return_value = {
|
||||
"session_id": "test_session_123",
|
||||
"verification_code": "123456",
|
||||
"expires_at": datetime.utcnow() + timedelta(minutes=10)
|
||||
}
|
||||
|
||||
auth_app.dependency_overrides[get_database] = lambda: db
|
||||
auth_app.dependency_overrides[get_dns_service] = lambda: mock_dns
|
||||
auth_app.dependency_overrides[get_email_service] = lambda: mock_email
|
||||
auth_app.dependency_overrides[get_html_fetcher] = lambda: mock_html
|
||||
auth_app.dependency_overrides[get_relme_parser] = lambda: mock_relme
|
||||
auth_app.dependency_overrides[get_auth_session_service] = lambda: mock_session
|
||||
|
||||
try:
|
||||
params = {
|
||||
"client_id": "https://app.example.com",
|
||||
"redirect_uri": "https://app.example.com/callback",
|
||||
"response_type": "code",
|
||||
"state": "test123",
|
||||
"code_challenge": "abc123",
|
||||
"code_challenge_method": "S256",
|
||||
"me": "https://user.example.com",
|
||||
}
|
||||
|
||||
with TestClient(auth_app) as client:
|
||||
response = client.get("/authorize", params=params)
|
||||
|
||||
assert "X-Frame-Options" in response.headers
|
||||
assert "X-Content-Type-Options" in response.headers
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
finally:
|
||||
auth_app.dependency_overrides.clear()
|
||||
|
||||
def test_error_pages_have_security_headers(self, auth_client):
|
||||
"""Test error pages include security headers."""
|
||||
|
||||
Reference in New Issue
Block a user