diff --git a/projects/policyengine-api-simulation/src/modal/gateway/auth.py b/projects/policyengine-api-simulation/src/modal/gateway/auth.py index 118d137e3..ee48731ab 100644 --- a/projects/policyengine-api-simulation/src/modal/gateway/auth.py +++ b/projects/policyengine-api-simulation/src/modal/gateway/auth.py @@ -12,7 +12,7 @@ - ``GATEWAY_AUTH_ISSUER`` - Auth0 issuer URL (must end with ``/``) - ``GATEWAY_AUTH_AUDIENCE`` - Auth0 API identifier the gateway accepts -- ``GATEWAY_AUTH_REQUIRED`` - if truthy, missing issuer/audience is a 503 +- ``GATEWAY_AUTH_REQUIRED`` - if truthy, bearer JWT auth is enforced For local development and unit tests the dependency can be bypassed by setting ``GATEWAY_AUTH_DISABLED=1``. This bypass is hard-gated by @@ -21,8 +21,8 @@ missing or looks like production, and otherwise requires an explicit ``GATEWAY_AUTH_DISABLED_ACK=I_UNDERSTAND_THIS_IS_DEV`` acknowledgement so the bypass cannot be activated by a single stray env var. The gateway -also returns ``503`` to callers if auth is required but the issuer/audience -configuration is missing, or if only one of issuer/audience is present. +also returns ``503`` to callers if only one of issuer/audience is present, or +if auth is required but issuer/audience are missing. """ from __future__ import annotations @@ -193,10 +193,11 @@ def require_auth( missing or invalid token produces a 403 (matching the underlying decoder's contract). - If issuer/audience env configuration is absent, the dependency preserves - the legacy public gateway behavior unless ``GATEWAY_AUTH_REQUIRED`` is - truthy. Partial auth configuration always returns 503 because it indicates - an operator intended to enable auth but shipped an incomplete secret. + The gateway preserves its legacy public behavior unless + ``GATEWAY_AUTH_REQUIRED`` is truthy. Issuer/audience may be staged in the + Modal secret ahead of enforcement; setting those values alone must not + silently make the gateway private. Partial auth configuration always + returns 503 because it indicates an incomplete secret. """ if _auth_disabled(): @@ -204,7 +205,17 @@ def require_auth( issuer = os.environ.get(GATEWAY_AUTH_ISSUER_ENV) audience = os.environ.get(GATEWAY_AUTH_AUDIENCE_ENV) - if not issuer and not audience and not _auth_required(): + if bool(issuer) != bool(audience): + logger.error( + "Gateway auth partially configured: issuer_present=%s audience_present=%s", + bool(issuer), + bool(audience), + ) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Gateway authentication is not configured.", + ) + if not _auth_required(): return None try: diff --git a/projects/policyengine-api-simulation/tests/gateway/test_auth.py b/projects/policyengine-api-simulation/tests/gateway/test_auth.py index da7f32a0a..c9033008f 100644 --- a/projects/policyengine-api-simulation/tests/gateway/test_auth.py +++ b/projects/policyengine-api-simulation/tests/gateway/test_auth.py @@ -48,6 +48,7 @@ def __call__(self, token): monkeypatch.setattr(auth_module, "_get_decoder", lambda: FailingDecoder()) monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1") monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/") monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud") @@ -84,6 +85,17 @@ def test__given_auth_not_configured_and_not_required__then_dependency_allows( assert auth_module.require_auth(token=None) is None +def test__given_auth_configured_but_not_required__then_dependency_allows( + monkeypatch, +): + monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.delenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, raising=False) + monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/") + monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud") + + assert auth_module.require_auth(token=None) is None + + def test__given_auth_required_and_misconfigured__then_dependency_raises_503( monkeypatch, ): @@ -169,6 +181,7 @@ def test__given_repeated_requests__then_decoder_not_reinstantiated(monkeypatch): from policyengine_fastapi.auth import jwt_decoder as jwt_decoder_module monkeypatch.delenv(auth_module.GATEWAY_AUTH_DISABLED_ENV, raising=False) + monkeypatch.setenv(auth_module.GATEWAY_AUTH_REQUIRED_ENV, "1") monkeypatch.setenv(auth_module.GATEWAY_AUTH_ISSUER_ENV, "https://issuer.example/") monkeypatch.setenv(auth_module.GATEWAY_AUTH_AUDIENCE_ENV, "aud-repeat")