Skip to content

Commit 80219e1

Browse files
committed
Fail closed for auth-enabled routes
1 parent c47f700 commit 80219e1

5 files changed

Lines changed: 148 additions & 13 deletions

File tree

changelog_entry.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- bump: patch
2+
changes:
3+
fixed:
4+
- Fail closed when auth is enabled without Auth0 configuration and require auth on calculate_demo.

policyengine_household_api/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def readiness_check():
8585

8686

8787
@app.route("/<country_id>/calculate_demo", methods=["POST"])
88+
@require_auth_if_enabled()
8889
@limiter.limit("1 per second")
8990
def calculate_demo(country_id):
9091
return get_calculate(country_id)

policyengine_household_api/decorators/auth.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,45 @@
88

99
from typing import Optional, Any, Callable
1010
from authlib.integrations.flask_oauth2 import ResourceProtector
11+
from authlib.oauth2.rfc6750 import BearerTokenValidator
1112
from ..auth.validation import Auth0JWTBearerTokenValidator
12-
from ..utils.config_loader import get_config, get_config_value
13+
from ..utils.config_loader import get_config_value
14+
15+
16+
class AuthConfigurationError(RuntimeError):
17+
"""Raised when authentication is enabled but required config is missing."""
18+
19+
20+
class StaticBearerToken:
21+
"""Minimal token object for test-only bearer token validation."""
22+
23+
def __init__(self, token_string: str, scope: str = ""):
24+
self.token_string = token_string
25+
self.scope = scope
26+
27+
def is_expired(self) -> bool:
28+
return False
29+
30+
def is_revoked(self) -> bool:
31+
return False
32+
33+
def get_scope(self) -> str:
34+
return self.scope
35+
36+
37+
class StaticBearerTokenValidator(BearerTokenValidator):
38+
"""Accept a single configured bearer token for test environments."""
39+
40+
def __init__(self, expected_token: str):
41+
super().__init__()
42+
self.expected_token = expected_token
43+
44+
def authenticate_token(
45+
self, token_string: Optional[str]
46+
) -> Optional[StaticBearerToken]:
47+
if token_string == self.expected_token:
48+
return StaticBearerToken(token_string)
49+
return None
1350

1451

1552
class NoOpDecorator:
@@ -63,14 +100,22 @@ def _setup_authentication(self) -> None:
63100
"""
64101
# Check if Auth0 is explicitly enabled via configuration
65102
self._auth_enabled = get_config_value("auth.enabled", False)
103+
app_environment = get_config_value("app.environment", "")
104+
auth0_test_token = get_config_value("auth.auth0.test_token", "")
66105

67106
# Get Auth0 configuration values
68107
auth0_address = get_config_value("auth.auth0.address", "")
69108
auth0_audience = get_config_value("auth.auth0.audience", "")
70109

71110
# Initialize the appropriate decorator
72111
if self._auth_enabled:
73-
if auth0_address and auth0_audience:
112+
if app_environment == "test_with_auth" and auth0_test_token:
113+
resource_protector = ResourceProtector()
114+
resource_protector.register_token_validator(
115+
StaticBearerTokenValidator(auth0_test_token)
116+
)
117+
self._decorator = resource_protector
118+
elif auth0_address and auth0_audience:
74119
# Set up real Auth0 authentication
75120
resource_protector = ResourceProtector()
76121
validator = Auth0JWTBearerTokenValidator(
@@ -79,10 +124,9 @@ def _setup_authentication(self) -> None:
79124
resource_protector.register_token_validator(validator)
80125
self._decorator = resource_protector
81126
else:
82-
# Auth was requested but configuration is missing
83-
print("Warning: Auth enabled but Auth0 configuration missing")
84-
self._auth_enabled = False
85-
self._decorator = NoOpDecorator()
127+
raise AuthConfigurationError(
128+
"Auth enabled but Auth0 configuration missing"
129+
)
86130
else:
87131
# Authentication is disabled
88132
self._decorator = NoOpDecorator()

tests/unit/decorators/test_auth.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22
Unit tests for the conditional authentication decorator.
33
"""
44

5+
import pytest
56
from unittest.mock import Mock
67
from policyengine_household_api.decorators.auth import (
78
NoOpDecorator,
89
ConditionalAuthDecorator,
10+
AuthConfigurationError,
911
create_auth_decorator,
12+
StaticBearerTokenValidator,
1013
)
1114
from tests.fixtures.decorators.auth import (
1215
AUTH0_CONFIG_DATA,
1316
auth_enabled_environment,
17+
auth_test_environment,
1418
auth_disabled_environment,
1519
auth_enabled_missing_config_environment,
1620
auth_backward_compat_environment,
@@ -66,6 +70,29 @@ def test__given_multiple_functions__each_passes_through_unchanged(self):
6670
class TestConditionalAuthDecoratorWithAuthEnabled:
6771
"""Test ConditionalAuthDecorator with authentication enabled."""
6872

73+
def test__given_test_auth_environment__uses_static_token_validator(
74+
self,
75+
auth_test_environment,
76+
mock_resource_protector,
77+
mock_auth0_validator,
78+
):
79+
_, mock_protector_instance = mock_resource_protector
80+
mock_validator_class, _ = mock_auth0_validator
81+
82+
decorator = ConditionalAuthDecorator()
83+
84+
mock_validator_class.assert_not_called()
85+
registered_validator = (
86+
mock_protector_instance.register_token_validator.call_args[0][0]
87+
)
88+
assert isinstance(registered_validator, StaticBearerTokenValidator)
89+
assert registered_validator.expected_token == "test-jwt-token"
90+
assert decorator.get_decorator() is mock_protector_instance
91+
assert decorator.is_enabled is True
92+
93+
auth_test_environment.assert_any_call("app.environment", "")
94+
auth_test_environment.assert_any_call("auth.auth0.test_token", "")
95+
6996
def test__given_auth_enabled_with_valid_config__auth0_is_configured(
7097
self,
7198
auth_enabled_environment,
@@ -97,26 +124,26 @@ def test__given_auth_enabled_with_valid_config__auth0_is_configured(
97124
auth_enabled_environment.assert_any_call("auth.auth0.address", "")
98125
auth_enabled_environment.assert_any_call("auth.auth0.audience", "")
99126

100-
def test__given_auth_enabled_missing_config__falls_back_to_noop(
127+
def test__given_auth_enabled_missing_config__raises_configuration_error(
101128
self,
102129
auth_enabled_missing_config_environment,
103130
mock_resource_protector,
104131
mock_auth0_validator,
105132
):
106-
"""Test fallback to NoOp when auth is enabled but config is missing."""
133+
"""Test auth fails closed when auth is enabled but config is missing."""
107134
mock_protector_class, _ = mock_resource_protector
108135
mock_validator_class, _ = mock_auth0_validator
109136

110-
decorator = ConditionalAuthDecorator()
137+
with pytest.raises(
138+
AuthConfigurationError,
139+
match="Auth enabled but Auth0 configuration missing",
140+
):
141+
ConditionalAuthDecorator()
111142

112143
# Verify Auth0 components were not created
113144
mock_validator_class.assert_not_called()
114145
mock_protector_class.assert_not_called()
115146

116-
# Verify we get a NoOpDecorator
117-
assert isinstance(decorator.get_decorator(), NoOpDecorator)
118-
assert decorator.is_enabled is False
119-
120147
# Verify configuration was checked
121148
auth_enabled_missing_config_environment.assert_any_call(
122149
"auth.enabled", False
@@ -182,3 +209,16 @@ def test__given_auth_disabled__returns_noop_decorator(
182209
decorator = create_auth_decorator()
183210

184211
assert isinstance(decorator, NoOpDecorator)
212+
213+
def test__given_auth_enabled_missing_config__raises_configuration_error(
214+
self,
215+
auth_enabled_missing_config_environment,
216+
mock_resource_protector,
217+
mock_auth0_validator,
218+
):
219+
"""Test that factory raises when auth is enabled but misconfigured."""
220+
with pytest.raises(
221+
AuthConfigurationError,
222+
match="Auth enabled but Auth0 configuration missing",
223+
):
224+
create_auth_decorator()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Regression tests for calculate_demo authentication."""
2+
3+
import importlib
4+
from contextlib import contextmanager
5+
from unittest.mock import patch
6+
7+
import policyengine_household_api.api as household_api
8+
import policyengine_household_api.decorators.auth as auth_module
9+
10+
11+
@contextmanager
12+
def _auth_enabled_app():
13+
config_values = {
14+
"app.environment": "test_with_auth",
15+
"auth.enabled": True,
16+
"auth.auth0.address": "test-tenant.auth0.com",
17+
"auth.auth0.audience": "https://test-api-identifier",
18+
"auth.auth0.test_token": "test-jwt-token",
19+
}
20+
21+
def get_config_value(path: str, default=None):
22+
return config_values.get(path, default)
23+
24+
with patch(
25+
"policyengine_household_api.utils.config_loader.get_config_value",
26+
side_effect=get_config_value,
27+
):
28+
reloaded_auth_module = importlib.reload(auth_module)
29+
reloaded_api_module = importlib.reload(household_api)
30+
try:
31+
yield reloaded_api_module
32+
finally:
33+
importlib.reload(reloaded_auth_module)
34+
importlib.reload(reloaded_api_module)
35+
36+
37+
def test_calculate_demo_requires_auth_when_auth_is_enabled():
38+
with _auth_enabled_app() as api_module:
39+
client = api_module.app.test_client()
40+
41+
response = client.post(
42+
"/us/calculate_demo",
43+
headers={"Content-Type": "application/json"},
44+
)
45+
46+
assert response.status_code == 401

0 commit comments

Comments
 (0)