diff --git a/README.md b/README.md index 7e914841..0ffb4335 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,29 @@ client.hris.directory.list_individuals( ) ``` +## Webhook Verification + +We provide helper methods for verifying that a webhook request came from Finch, and not a malicious third party. + +You can use `finch.webhooks.verify_signature(body: string, headers, secret?) -> None` or `finch.webhooks.unwrap(body: string, headers, secret?) -> Payload`, +both of which will raise an error if the signature is invalid. + +Note that the "body" parameter must be the raw JSON string sent from the server (do not parse it first). +The `.unwrap()` method can parse this JSON for you into a `Payload` object. + +For example, in [FastAPI](https://fastapi.tiangolo.com/): + +```py +@app.post('/my-webhook-handler') +async def handler(request: Request): + body = await request.body() + secret = os.environ['FINCH_WEBHOOK_SECRET'] # env var used by default; explicit here. + payload = client.webhooks.unwrap(body, request.headers, secret) + print(payload) + + return {'ok': True} +``` + ## Handling errors When the library is unable to connect to the API (e.g., due to network connection problems or a timeout), a subclass of `finch.APIConnectionError` is raised. diff --git a/api.md b/api.md index 4205ef26..9c37bfe7 100644 --- a/api.md +++ b/api.md @@ -223,3 +223,10 @@ Methods: - client.account.disconnect() -> DisconnectResponse - client.account.introspect() -> Introspection + +# Webhooks + +Methods: + +- client.webhooks.unwrap(\*args) -> object +- client.webhooks.verify_signature(\*args) -> None diff --git a/src/finch/_client.py b/src/finch/_client.py index 1efed683..2399240c 100644 --- a/src/finch/_client.py +++ b/src/finch/_client.py @@ -49,17 +49,20 @@ class Finch(SyncAPIClient): ats: resources.ATS providers: resources.Providers account: resources.Account + webhooks: resources.Webhooks # client options access_token: str | None client_id: str | None client_secret: str | None + webhook_secret: str | None def __init__( self, *, client_id: str | None = None, client_secret: str | None = None, + webhook_secret: str | None = None, base_url: Optional[str] = None, access_token: Optional[str] = None, timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, @@ -87,6 +90,7 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `client_id` from `FINCH_CLIENT_ID` - `client_secret` from `FINCH_CLIENT_SECRET` + - `webhook_secret` from `FINCH_WEBHOOK_SECRET` """ self.access_token = access_token @@ -96,6 +100,9 @@ def __init__( client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None) self.client_secret = client_secret or client_secret_envvar or None + webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None) + self.webhook_secret = webhook_secret or webhook_secret_envvar or None + if base_url is None: base_url = f"https://api.tryfinch.com" @@ -116,6 +123,7 @@ def __init__( self.ats = resources.ATS(self) self.providers = resources.Providers(self) self.account = resources.Account(self) + self.webhooks = resources.Webhooks(self) @property def qs(self) -> Querystring: @@ -151,6 +159,7 @@ def copy( *, client_id: str | None = None, client_secret: str | None = None, + webhook_secret: str | None = None, access_token: str | None = None, base_url: str | None = None, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, @@ -189,6 +198,7 @@ def copy( return self.__class__( client_id=client_id or self.client_id, client_secret=client_secret or self.client_secret, + webhook_secret=webhook_secret or self.webhook_secret, base_url=base_url or str(self.base_url), access_token=access_token or self.access_token, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, @@ -272,17 +282,20 @@ class AsyncFinch(AsyncAPIClient): ats: resources.AsyncATS providers: resources.AsyncProviders account: resources.AsyncAccount + webhooks: resources.AsyncWebhooks # client options access_token: str | None client_id: str | None client_secret: str | None + webhook_secret: str | None def __init__( self, *, client_id: str | None = None, client_secret: str | None = None, + webhook_secret: str | None = None, base_url: Optional[str] = None, access_token: Optional[str] = None, timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, @@ -310,6 +323,7 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `client_id` from `FINCH_CLIENT_ID` - `client_secret` from `FINCH_CLIENT_SECRET` + - `webhook_secret` from `FINCH_WEBHOOK_SECRET` """ self.access_token = access_token @@ -319,6 +333,9 @@ def __init__( client_secret_envvar = os.environ.get("FINCH_CLIENT_SECRET", None) self.client_secret = client_secret or client_secret_envvar or None + webhook_secret_envvar = os.environ.get("FINCH_WEBHOOK_SECRET", None) + self.webhook_secret = webhook_secret or webhook_secret_envvar or None + if base_url is None: base_url = f"https://api.tryfinch.com" @@ -339,6 +356,7 @@ def __init__( self.ats = resources.AsyncATS(self) self.providers = resources.AsyncProviders(self) self.account = resources.AsyncAccount(self) + self.webhooks = resources.AsyncWebhooks(self) @property def qs(self) -> Querystring: @@ -374,6 +392,7 @@ def copy( *, client_id: str | None = None, client_secret: str | None = None, + webhook_secret: str | None = None, access_token: str | None = None, base_url: str | None = None, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, @@ -412,6 +431,7 @@ def copy( return self.__class__( client_id=client_id or self.client_id, client_secret=client_secret or self.client_secret, + webhook_secret=webhook_secret or self.webhook_secret, base_url=base_url or str(self.base_url), access_token=access_token or self.access_token, timeout=self.timeout if isinstance(timeout, NotGiven) else timeout, diff --git a/src/finch/resources/__init__.py b/src/finch/resources/__init__.py index 4c349ae2..2008f217 100644 --- a/src/finch/resources/__init__.py +++ b/src/finch/resources/__init__.py @@ -3,6 +3,18 @@ from .ats import ATS, AsyncATS from .hris import HRIS, AsyncHRIS from .account import Account, AsyncAccount +from .webhooks import Webhooks, AsyncWebhooks from .providers import Providers, AsyncProviders -__all__ = ["HRIS", "AsyncHRIS", "ATS", "AsyncATS", "Providers", "AsyncProviders", "Account", "AsyncAccount"] +__all__ = [ + "HRIS", + "AsyncHRIS", + "ATS", + "AsyncATS", + "Providers", + "AsyncProviders", + "Account", + "AsyncAccount", + "Webhooks", + "AsyncWebhooks", +] diff --git a/src/finch/resources/webhooks.py b/src/finch/resources/webhooks.py new file mode 100644 index 00000000..f5388ae5 --- /dev/null +++ b/src/finch/resources/webhooks.py @@ -0,0 +1,215 @@ +# File generated from our OpenAPI spec by Stainless. + +from __future__ import annotations + +import hmac +import json +import math +import base64 +import hashlib +from datetime import datetime, timezone, timedelta + +from .._types import HeadersLike +from .._resource import SyncAPIResource, AsyncAPIResource + +__all__ = ["Webhooks", "AsyncWebhooks"] + + +class Webhooks(SyncAPIResource): + def unwrap( + self, + payload: str | bytes, + headers: HeadersLike, + *, + secret: str | None = None, + ) -> object: + """Validates that the given payload was sent by Finch and parses the payload.""" + self.verify_signature(payload=payload, headers=headers, secret=secret) + return json.loads(payload) + + def verify_signature( + self, + payload: str | bytes, + headers: HeadersLike, + *, + secret: str | None = None, + ) -> None: + """Validates whether or not the webhook payload was sent by Finch. + + An error will be raised if the webhook payload was not sent by Finch. + """ + if secret is None: + secret = self._client.webhook_secret + + if secret is None: + raise ValueError( + "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function" + ) + + try: + parsedSecret = base64.b64decode(secret) + except Exception: + raise ValueError("Bad secret") + + msg_id = headers.get("finch-event-id") + if not msg_id: + raise ValueError("Could not find finch-event-id header") + + msg_timestamp = headers.get("finch-timestamp") + if not msg_timestamp: + raise ValueError("Could not find finch-timestamp header") + + # validate the timestamp + webhook_tolerance = timedelta(minutes=5) + now = datetime.now(tz=timezone.utc) + + try: + timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc) + except Exception: + raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp") + + # too old + if timestamp < (now - webhook_tolerance): + raise ValueError("Webhook timestamp is too old") + + # too new + if timestamp > (now + webhook_tolerance): + raise ValueError("Webhook timestamp is too new") + + # create the signature + body = payload.decode("utf-8") if isinstance(payload, bytes) else payload + if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise ValueError( + "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary." + ) + + timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp())) + + to_sign = f"{msg_id}.{timestamp_str}.{body}".encode() + expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest() + + msg_signature = headers.get("finch-signature") + if not msg_signature: + raise ValueError("Could not find finch-signature header") + + # Signature header can contain multiple signatures delimited by spaces + passed_sigs = msg_signature.split(" ") + + for versioned_sig in passed_sigs: + values = versioned_sig.split(",") + if len(values) != 2: + # signature is not formatted like {version},{signature} + continue + + (version, signature) = values + + # Only verify prefix v1 + if version != "v1": + continue + + sig_bytes = base64.b64decode(signature) + if hmac.compare_digest(expected_signature, sig_bytes): + # valid! + return None + + raise ValueError("None of the given webhook signatures match the expected signature") + + +class AsyncWebhooks(AsyncAPIResource): + def unwrap( + self, + payload: str | bytes, + headers: HeadersLike, + *, + secret: str | None = None, + ) -> object: + """Validates that the given payload was sent by Finch and parses the payload.""" + self.verify_signature(payload=payload, headers=headers, secret=secret) + return json.loads(payload) + + def verify_signature( + self, + payload: str | bytes, + headers: HeadersLike, + *, + secret: str | None = None, + ) -> None: + """Validates whether or not the webhook payload was sent by Finch. + + An error will be raised if the webhook payload was not sent by Finch. + """ + if secret is None: + secret = self._client.webhook_secret + + if secret is None: + raise ValueError( + "The webhook secret must either be set using the env var, FINCH_WEBHOOK_SECRET, on the client class, Finch(webhook_secret='123'), or passed to this function" + ) + + try: + parsedSecret = base64.b64decode(secret) + except Exception: + raise ValueError("Bad secret") + + msg_id = headers.get("finch-event-id") + if not msg_id: + raise ValueError("Could not find finch-event-id header") + + msg_timestamp = headers.get("finch-timestamp") + if not msg_timestamp: + raise ValueError("Could not find finch-timestamp header") + + # validate the timestamp + webhook_tolerance = timedelta(minutes=5) + now = datetime.now(tz=timezone.utc) + + try: + timestamp = datetime.fromtimestamp(float(msg_timestamp), tz=timezone.utc) + except Exception: + raise ValueError("Invalid timestamp header: " + msg_timestamp + ". Could not convert to timestamp") + + # too old + if timestamp < (now - webhook_tolerance): + raise ValueError("Webhook timestamp is too old") + + # too new + if timestamp > (now + webhook_tolerance): + raise ValueError("Webhook timestamp is too new") + + # create the signature + body = payload.decode("utf-8") if isinstance(payload, bytes) else payload + if not isinstance(body, str): # pyright: ignore[reportUnnecessaryIsInstance] + raise ValueError( + "Webhook body should be a string of JSON (or bytes which can be decoded to a utf-8 string), not a parsed dictionary." + ) + + timestamp_str = str(math.floor(timestamp.replace(tzinfo=timezone.utc).timestamp())) + + to_sign = f"{msg_id}.{timestamp_str}.{body}".encode() + expected_signature = hmac.new(parsedSecret, to_sign, hashlib.sha256).digest() + + msg_signature = headers.get("finch-signature") + if not msg_signature: + raise ValueError("Could not find finch-signature header") + + # Signature header can contain multiple signatures delimited by spaces + passed_sigs = msg_signature.split(" ") + + for versioned_sig in passed_sigs: + values = versioned_sig.split(",") + if len(values) != 2: + # signature is not formatted like {version},{signature} + continue + + (version, signature) = values + + # Only verify prefix v1 + if version != "v1": + continue + + sig_bytes = base64.b64decode(signature) + if hmac.compare_digest(expected_signature, sig_bytes): + # valid! + return None + + raise ValueError("None of the given webhook signatures match the expected signature") diff --git a/tests/api_resources/test_webhooks.py b/tests/api_resources/test_webhooks.py new file mode 100644 index 00000000..61175521 --- /dev/null +++ b/tests/api_resources/test_webhooks.py @@ -0,0 +1,216 @@ +# File generated from our OpenAPI spec by Stainless. + +from __future__ import annotations + +import os +import base64 +from typing import Any, cast +from datetime import datetime, timezone, timedelta + +import pytest +import time_machine + +from finch import Finch, AsyncFinch + +base_url = os.environ.get("API_BASE_URL", "http://127.0.0.1:4010") +access_token = os.environ.get("API_KEY", "something1234") + + +class TestWebhooks: + strict_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=True) + loose_client = Finch(base_url=base_url, access_token=access_token, _strict_response_validation=False) + parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"]) + + timestamp = "1676312382" + fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc) + + payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}""" + signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s=" + headers = { + "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj", + "finch-timestamp": timestamp, + "finch-signature": f"v1,{signature}", + } + secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH" + + @time_machine.travel(fake_now) + def test_unwrap(self) -> None: + payload = self.payload + headers = self.headers + secret = self.secret + + self.strict_client.webhooks.unwrap(payload, headers, secret=secret) + + @time_machine.travel(fake_now) + def test_verify_signature(self) -> None: + payload = self.payload + headers = self.headers + secret = self.secret + signature = self.signature + verify = self.strict_client.webhooks.verify_signature + + assert verify(payload=payload, headers=headers, secret=secret) is None + + with pytest.raises(ValueError, match="Webhook timestamp is too old"): + with time_machine.travel(self.fake_now + timedelta(minutes=6)): + assert verify(payload=payload, headers=headers, secret=secret) is False + + with pytest.raises(ValueError, match="Webhook timestamp is too new"): + with time_machine.travel(self.fake_now - timedelta(minutes=6)): + assert verify(payload=payload, headers=headers, secret=secret) is False + + # wrong secret + with pytest.raises(ValueError, match=r"Bad secret"): + verify(payload=payload, headers=headers, secret="invalid secret") + + invalid_signature_message = "None of the given webhook signatures match the expected signature" + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers=headers, + secret=base64.b64encode(b"foo").decode("utf-8"), + ) + + # multiple signatures + invalid_signature = base64.b64encode(b"my_sig").decode("utf-8") + assert ( + verify( + payload=payload, + headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"}, + secret=secret, + ) + is None + ) + + # different signaature version + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers={**headers, "finch-signature": f"v2,{signature}"}, + secret=secret, + ) + + assert ( + verify( + payload=payload, + headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"}, + secret=secret, + ) + is None + ) + + # missing version + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers={**headers, "finch-signature": signature}, + secret=secret, + ) + + # non-string payload + with pytest.raises(ValueError, match=r"Webhook body should be a string"): + verify( + payload=cast(Any, {"payload": payload}), + headers=headers, + secret=secret, + ) + + +class TestAsyncWebhooks: + strict_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=True) + loose_client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=False) + parametrize = pytest.mark.parametrize("client", [strict_client, loose_client], ids=["strict", "loose"]) + + timestamp = "1676312382" + fake_now = datetime.fromtimestamp(float(timestamp), tz=timezone.utc) + + payload = """{"company_id":"720be419-0293-4d32-a707-32179b0827ab"}""" + signature = "m7y0TV2C+hlHxU42wCieApTSTaA8/047OAplBqxIV/s=" + headers = { + "finch-event-id": "msg_2Lh9KRb0pzN4LePd3XiA4v12Axj", + "finch-timestamp": timestamp, + "finch-signature": f"v1,{signature}", + } + secret = "5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH" + + @time_machine.travel(fake_now) + def test_unwrap(self) -> None: + payload = self.payload + headers = self.headers + secret = self.secret + + self.strict_client.webhooks.unwrap(payload, headers, secret=secret) + + @time_machine.travel(fake_now) + def test_verify_signature(self) -> None: + payload = self.payload + headers = self.headers + secret = self.secret + signature = self.signature + verify = self.strict_client.webhooks.verify_signature + + assert verify(payload=payload, headers=headers, secret=secret) is None + + with pytest.raises(ValueError, match="Webhook timestamp is too old"): + with time_machine.travel(self.fake_now + timedelta(minutes=6)): + assert verify(payload=payload, headers=headers, secret=secret) is False + + with pytest.raises(ValueError, match="Webhook timestamp is too new"): + with time_machine.travel(self.fake_now - timedelta(minutes=6)): + assert verify(payload=payload, headers=headers, secret=secret) is False + + # wrong secret + with pytest.raises(ValueError, match=r"Bad secret"): + verify(payload=payload, headers=headers, secret="invalid secret") + + invalid_signature_message = "None of the given webhook signatures match the expected signature" + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers=headers, + secret=base64.b64encode(b"foo").decode("utf-8"), + ) + + # multiple signatures + invalid_signature = base64.b64encode(b"my_sig").decode("utf-8") + assert ( + verify( + payload=payload, + headers={**headers, "finch-signature": f"v1,{invalid_signature} v1,{signature}"}, + secret=secret, + ) + is None + ) + + # different signaature version + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers={**headers, "finch-signature": f"v2,{signature}"}, + secret=secret, + ) + + assert ( + verify( + payload=payload, + headers={**headers, "finch-signature": f"v2,{signature} v1,{signature}"}, + secret=secret, + ) + is None + ) + + # missing version + with pytest.raises(ValueError, match=invalid_signature_message): + verify( + payload=payload, + headers={**headers, "finch-signature": signature}, + secret=secret, + ) + + # non-string payload + with pytest.raises(ValueError, match=r"Webhook body should be a string"): + verify( + payload=cast(Any, {"payload": payload}), + headers=headers, + secret=secret, + )