fix(auth): distinguish portal verification outages

This commit is contained in:
Adam Moussa 2026-09-15 17:52:49 -04:00
parent 387bf64b6b
commit 28ec13584a
No known key found for this signature in database
6 changed files with 66 additions and 15 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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"}
)

View file

@ -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):

View file

@ -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"]},