diff --git a/examples/event_notification_handler_endpoint.py b/examples/event_notification_handler_endpoint.py index e7d121c9c..6ff52aa53 100644 --- a/examples/event_notification_handler_endpoint.py +++ b/examples/event_notification_handler_endpoint.py @@ -34,9 +34,17 @@ def fallback_callback( client = StripeClient(api_key) handler = client.notification_handler(webhook_secret, fallback_callback) +# Handles events delivered through a channel that has already authenticated them, such as +# AWS EventBridge or Azure Event Grid. Those payloads carry no Stripe-Signature header. +unverified_handler = client.notification_handler_without_verification( + fallback_callback +) -# can be anywhere in your codebase + +# can be anywhere in your codebase; registering on both handlers means either +# endpoint below will route this event type @handler.on_v1_billing_meter_error_report_triggered +@unverified_handler.on_v1_billing_meter_error_report_triggered def handle_meter_error( notif: V1BillingMeterErrorReportTriggeredEventNotification, client: StripeClient, @@ -55,3 +63,13 @@ def webhook(): return jsonify(success=True), 200 except Exception as e: return jsonify(error=str(e)), 500 + + +@app.route("/webhook-from-cloud-provider", methods=["POST"]) +def webhook_from_cloud_provider(): + # no signature header to pass along; the channel already authenticated this event + try: + unverified_handler.handle(request.data) + return jsonify(success=True), 200 + except Exception as e: + return jsonify(error=str(e)), 500 diff --git a/stripe/_event_notification_handler.py b/stripe/_event_notification_handler.py index 34d8bcce1..cf98e530f 100644 --- a/stripe/_event_notification_handler.py +++ b/stripe/_event_notification_handler.py @@ -298,28 +298,32 @@ class UnhandledNotificationDetails: """ -class StripeEventNotificationHandler: +class _StripeEventNotificationHandlerWithoutVerification: def __init__( self, client: "StripeClient", - webhook_secret: str, fallback_callback: FallbackCallback, ) -> None: self._registered_handlers = {} self._client = client - self._webhook_secret = webhook_secret self.fallback_callback = fallback_callback # once this is true, adding additional handlers results in an error self._has_handled_events = False - def handle(self, webhook_body: str, sig_header: str): - # isn't thread-safe, but we expect these to get registered synchronously at startup + def handle(self, webhook_body: str): + # modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup + # making a race condition here unlikely self._has_handled_events = True - event_notif = self._client.parse_event_notification( - webhook_body, sig_header, self._webhook_secret + event_notif = ( + self._client.parse_event_notification_without_verification( + webhook_body + ) ) + self._dispatch(event_notif) + + def _dispatch(self, event_notif: "EventNotification"): # Create a new client with the event's context. # This is thread-safe since we're not modifying the original client. # The new client reuses the HTTP client to avoid TLS handshake overhead. @@ -1485,3 +1489,42 @@ def on_v2_orchestrated_commerce_agreement_terminated( return func # event-notification-registration-methods: The end of the section generated from our OpenAPI spec + + +class StripeEventNotificationHandler( + _StripeEventNotificationHandlerWithoutVerification +): + """ + A more on-rails experience for handling Stripe event notifications. Define callbacks for individual event types and an instance of this class will be responsible for verifying and routing the event. + """ + + def __init__( + self, + client: "StripeClient", + webhook_secret: str, + fallback_callback: FallbackCallback, + ) -> None: + super().__init__(client, fallback_callback) + if not webhook_secret: + raise ValueError("webhook_secret must be a non-empty string") + self._webhook_secret = webhook_secret + + def handle(self, webhook_body: str, sig_header: str): + # modification isn't thread-safe, but we expect callbacks to get registered synchronously at startup + # making a race condition here unlikely + self._has_handled_events = True + + event_notif = self._client.parse_event_notification( + webhook_body, sig_header, self._webhook_secret + ) + + self._dispatch(event_notif) + + @staticmethod + def without_verification( + client: "StripeClient", + fallback_callback: FallbackCallback, + ) -> "_StripeEventNotificationHandlerWithoutVerification": + return _StripeEventNotificationHandlerWithoutVerification( + client, fallback_callback + ) diff --git a/stripe/_stripe_client.py b/stripe/_stripe_client.py index 93f0349f0..08bc955d2 100644 --- a/stripe/_stripe_client.py +++ b/stripe/_stripe_client.py @@ -11,6 +11,7 @@ from stripe._error import AuthenticationError from stripe._event_notification_handler import ( StripeEventNotificationHandler, + _StripeEventNotificationHandlerWithoutVerification, FallbackCallback, ) from stripe._request_options import extract_options_from_dict @@ -379,6 +380,19 @@ def notification_handler( self, webhook_secret, fallback_callback ) + def notification_handler_without_verification( + self, fallback_callback: FallbackCallback + ) -> _StripeEventNotificationHandlerWithoutVerification: + """ + A variant of StripeEventNotificationHandler that parses events without + verifying webhook signatures. Intended for pre-authenticated channels + like AWS EventBridge, Azure Event Grid, or your own queue system that + verifies payloads before storage. + """ + return StripeEventNotificationHandler.without_verification( + self, fallback_callback + ) + # deprecated v1 services: The beginning of the section generated from our OpenAPI spec @property @deprecated( diff --git a/tests/test_event_notification_handler.py b/tests/test_event_notification_handler.py index ddbf4c8fb..11b2c7936 100644 --- a/tests/test_event_notification_handler.py +++ b/tests/test_event_notification_handler.py @@ -555,3 +555,235 @@ def rand_int(notif, client): return 4 assert rand_int(None, None) == 4 # type: ignore + + def test_rejects_empty_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects an empty webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret="", + fallback_callback=fallback_callback, + ) + + def test_rejects_none_webhook_secret( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + """Test that the constructor rejects a None webhook secret""" + with pytest.raises( + ValueError, match="webhook_secret must be a non-empty string" + ): + StripeEventNotificationHandler( + client=stripe_client, + webhook_secret=None, # type: ignore + fallback_callback=fallback_callback, + ) + + +class TestEventNotificationHandlerWithoutVerification: + @pytest.fixture(scope="function") + def stripe_client(self, http_client_mock: HTTPClientMock) -> StripeClient: + return StripeClient( + api_key="sk_test_1234", + stripe_context=StripeContext.parse("original_context_123"), + http_client=http_client_mock.get_mock_http_client(), + ) + + @pytest.fixture(scope="function") + def fallback_callback(self) -> Mock: + return Mock() + + @pytest.fixture(scope="function") + def handler_without_verification( + self, stripe_client: StripeClient, fallback_callback: Mock + ): + return StripeEventNotificationHandler.without_verification( + client=stripe_client, + fallback_callback=fallback_callback, + ) + + @pytest.fixture(scope="function") + def v1_billing_meter_payload(self) -> str: + return json.dumps( + { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + ) + + @pytest.fixture(scope="function") + def unknown_event_payload(self) -> str: + return json.dumps( + { + "id": "evt_unknown", + "object": "v2.core.event", + "type": "llama.created", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_unknown", + "related_object": { + "id": "llama_123", + "type": "llama", + "url": "/v1/llamas/llama_123", + }, + } + ) + + def test_routes_event_to_registered_handler( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + ) + fallback_callback.assert_not_called() + + def test_handle_takes_single_argument( + self, + handler_without_verification, + v1_billing_meter_payload: str, + ) -> None: + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + + # No signature needed - just the payload + handler_without_verification.handle(v1_billing_meter_payload) + + handler.assert_called_once() + + def test_fallback_receives_unregistered_events( + self, + handler_without_verification, + v1_billing_meter_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(v1_billing_meter_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert isinstance(info, UnhandledNotificationDetails) + assert info.is_known_event_type is True + + def test_unknown_event_has_is_known_event_type_false( + self, + handler_without_verification, + unknown_event_payload: str, + fallback_callback: Mock, + ) -> None: + handler_without_verification.handle(unknown_event_payload) + + fallback_callback.assert_called_once() + info = fallback_callback.call_args[0][2] + assert info.is_known_event_type is False + + def test_context_propagation( + self, + handler_without_verification, + v1_billing_meter_payload: str, + stripe_client: StripeClient, + ) -> None: + received_context = None + + def handler(event, client): + nonlocal received_context + received_context = client._requestor._options.stripe_context + + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(v1_billing_meter_payload) + + assert str(received_context) == "event_context_456" + assert ( + str(stripe_client._requestor._options.stripe_context) + == "original_context_123" + ) + + def test_static_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + from stripe._event_notification_handler import ( + _StripeEventNotificationHandlerWithoutVerification, + ) + + handler = StripeEventNotificationHandler.without_verification( + stripe_client, fallback_callback + ) + assert isinstance( + handler, _StripeEventNotificationHandlerWithoutVerification + ) + + def test_client_factory( + self, stripe_client: StripeClient, fallback_callback: Mock + ) -> None: + handler = stripe_client.notification_handler_without_verification( + fallback_callback + ) + assert handler is not None + assert hasattr(handler, "handle") + + def test_handles_cloud_provider_envelope( + self, + handler_without_verification, + ) -> None: + """Test that events wrapped in cloud provider envelopes are parsed correctly""" + inner_payload = { + "id": "evt_123", + "object": "v2.core.event", + "type": "v1.billing.meter.error_report_triggered", + "livemode": False, + "created": "2022-02-15T00:27:45.330Z", + "context": "event_context_456", + "related_object": { + "id": "mtr_123", + "type": "billing.meter", + "url": "/v1/billing/meters/mtr_123", + }, + } + # AWS EventBridge envelope + eventbridge_payload = json.dumps( + { + "version": "0", + "id": "abc-123", + "source": "aws.partner/stripe.com/ed_xxx", + "detail-type": "event", + "detail": inner_payload, + } + ) + + handler = Mock() + handler_without_verification.on_v1_billing_meter_error_report_triggered( + handler + ) + handler_without_verification.handle(eventbridge_payload) + + handler.assert_called_once() + call_args = handler.call_args[0] + assert isinstance( + call_args[0], V1BillingMeterErrorReportTriggeredEventNotification + )