diff --git a/functions/admin_authorizer/handler.py b/functions/admin_authorizer/handler.py index e358130..f406d76 100644 --- a/functions/admin_authorizer/handler.py +++ b/functions/admin_authorizer/handler.py @@ -18,7 +18,11 @@ import urllib.error import urllib.parse import urllib.request -from shared.cognito import looks_like_cognito_token, verify_cognito_id_token +from shared.cognito import ( + CognitoVerificationUnavailable, + looks_like_cognito_token, + verify_cognito_id_token, +) from shared.db import get_settings from shared.secrets import get_parameter @@ -101,7 +105,11 @@ def lambda_handler(event, context): return _DENY if looks_like_cognito_token(token): - user_info = _verify_portal_token(token) + try: + user_info = _verify_portal_token(token) + except CognitoVerificationUnavailable: + logger.error("Cognito verification service unavailable; denying") + return _DENY else: if not os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""): logger.error("Authorizer misconfigured: GOOGLE_CLIENT_ID_PARAM unset") diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index e39c220..f8159cc 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -13,7 +13,11 @@ from zoneinfo import ZoneInfo import boto3 -from shared.cognito import looks_like_cognito_token, verify_cognito_id_token +from shared.cognito import ( + CognitoVerificationUnavailable, + looks_like_cognito_token, + verify_cognito_id_token, +) from shared.db import ( current_week, delete_order, @@ -180,7 +184,10 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]: return None, response(403, {"error": "Authentication required"}) if looks_like_cognito_token(token): - user_info = _verify_portal_token(token) + try: + user_info = _verify_portal_token(token) + except CognitoVerificationUnavailable: + return None, _authentication_service_unavailable() if user_info is None: return None, response( 403, {"error": "Invalid or unauthorized portal account"} @@ -541,7 +548,10 @@ def handle_submit(event): portal_token = _extract_bearer_token(event) if portal_token: - user_info = _verify_portal_token(portal_token) + try: + user_info = _verify_portal_token(portal_token) + except CognitoVerificationUnavailable: + return _authentication_service_unavailable() if user_info is None: return response(403, {"error": "Invalid or unauthorized portal account"}) else: diff --git a/src/shared/shared/cognito.py b/src/shared/shared/cognito.py index eb2df09..b4617f1 100644 --- a/src/shared/shared/cognito.py +++ b/src/shared/shared/cognito.py @@ -6,7 +6,7 @@ from functools import lru_cache import jwt from jwt import PyJWKClient -from jwt.exceptions import PyJWTError +from jwt.exceptions import PyJWKClientConnectionError, PyJWKClientError, PyJWTError from shared.secrets import get_parameter @@ -18,6 +18,10 @@ _COGNITO_ISSUER_RE = re.compile( ) +class CognitoVerificationUnavailable(RuntimeError): + """Trusted Cognito configuration or JWKS could not be loaded.""" + + def looks_like_cognito_token(token: str) -> bool: """Return whether untrusted claims identify a Cognito ID token.""" try: @@ -62,11 +66,11 @@ def verify_cognito_id_token( audience = get_parameter(audience_param, decrypt=False) except Exception as exc: logger.error("Failed to load Cognito verification parameters: %s", exc) - return None + raise CognitoVerificationUnavailable from exc if not _COGNITO_ISSUER_RE.fullmatch(issuer) or not audience: logger.error("Cognito verification parameters are invalid") - return None + raise CognitoVerificationUnavailable try: signing_key = _jwk_client(issuer).get_signing_key_from_jwt(token) @@ -89,7 +93,13 @@ def verify_cognito_id_token( ] }, ) - except Exception as exc: + except PyJWKClientConnectionError as exc: + logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__) + raise CognitoVerificationUnavailable from exc + except OSError as exc: + logger.error("Cognito JWKS is unavailable: %s", type(exc).__name__) + raise CognitoVerificationUnavailable from exc + except (PyJWKClientError, PyJWTError, TypeError, ValueError) as exc: logger.warning("Cognito token rejected: %s", type(exc).__name__) return None diff --git a/tests/test_admin_authorizer.py b/tests/test_admin_authorizer.py index 192a30c..443b8f7 100644 --- a/tests/test_admin_authorizer.py +++ b/tests/test_admin_authorizer.py @@ -102,6 +102,17 @@ def test_invalid_portal_token_denied_without_google_fallback( mock_google.assert_not_called() +@patch( + "admin_authorizer_handler._verify_portal_token", + side_effect=authorizer.CognitoVerificationUnavailable, +) +@patch("admin_authorizer_handler.looks_like_cognito_token", return_value=True) +def test_portal_verification_outage_denied(mock_looks_like, mock_verify): + assert authorizer.lambda_handler(_event("portal-id-token"), None) == { + "isAuthorized": False + } + + @patch.dict( os.environ, {"GOOGLE_CLIENT_ID_PARAM": "/meal-order-manager/google-client-id"} ) diff --git a/tests/test_cognito.py b/tests/test_cognito.py index 8c12779..340224e 100644 --- a/tests/test_cognito.py +++ b/tests/test_cognito.py @@ -107,12 +107,10 @@ def test_ssm_failure_is_rejected(signing_key): with patch( "shared.cognito.get_parameter", side_effect=RuntimeError("SSM unavailable") ): - assert ( + with pytest.raises(cognito.CognitoVerificationUnavailable): cognito.verify_cognito_id_token( _token(private_key), ISSUER_PARAM, AUDIENCE_PARAM ) - is None - ) def test_jwks_failure_is_rejected(signing_key): @@ -123,12 +121,10 @@ def test_jwks_failure_is_rejected(signing_key): patch("shared.cognito.get_parameter", side_effect=_parameter), patch("shared.cognito._jwk_client", return_value=jwks), ): - assert ( + with pytest.raises(cognito.CognitoVerificationUnavailable): cognito.verify_cognito_id_token( _token(private_key), ISSUER_PARAM, AUDIENCE_PARAM ) - is None - ) def test_cognito_token_detection_is_untrusted_routing_hint(signing_key): diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index 7dc0796..161fee8 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -680,6 +680,22 @@ def test_invalid_portal_token_never_falls_back_to_google_or_body( mock_put.assert_not_called() +@patch("submit_order_handler.put_order") +@patch( + "submit_order_handler._verify_portal_token", + side_effect=submit_order_handler.CognitoVerificationUnavailable, +) +def test_portal_verification_outage_returns_503(mock_portal, mock_put): + event = _submit_event(_make_items([(10.00, 1)])) + event["headers"] = {"authorization": "Bearer portal-id-token"} + + status, body = _parse_response(submit_order_handler.lambda_handler(event, None)) + + assert status == 503 + assert body == {"error": "Authentication service temporarily unavailable"} + mock_put.assert_not_called() + + @patch( "submit_order_handler.get_settings", return_value={"admin_emails": ["portal.admin@seahaven.com"]},