From 4e1ffa06072f808d99f2d86f7f79dd36d550aaec Mon Sep 17 00:00:00 2001 From: Maarten Jacobs Date: Tue, 20 Feb 2024 16:36:53 +0000 Subject: [PATCH] Add webhook plug based on Stripe equivalent --- lib/docusign/webhook/crypto.ex | 35 ++++ lib/docusign/webhook/handler.ex | 9 + lib/docusign/webhook_plug.ex | 242 ++++++++++++++++++++++ test/docusign/webhook_plug_test.exs | 309 ++++++++++++++++++++++++++++ 4 files changed, 595 insertions(+) create mode 100644 lib/docusign/webhook/crypto.ex create mode 100644 lib/docusign/webhook/handler.ex create mode 100644 lib/docusign/webhook_plug.ex create mode 100644 test/docusign/webhook_plug_test.exs diff --git a/lib/docusign/webhook/crypto.ex b/lib/docusign/webhook/crypto.ex new file mode 100644 index 0000000..a5e3e50 --- /dev/null +++ b/lib/docusign/webhook/crypto.ex @@ -0,0 +1,35 @@ +defmodule DocuSign.Webhook.Crypto do + @moduledoc """ + Crypto functions for DocuSign HMAC signature validation. + """ + + @type hmac256_key :: binary() + @type request_body :: binary() + @type signature :: binary() + + @doc """ + Verify HMAC-SHA256 signature. + """ + @spec verify_hmac(hmac256_key(), request_body(), signature()) :: boolean() + def verify_hmac(hmac256_key, request_body, signature) do + hmac = :crypto.mac(:hmac, :sha256, hmac256_key, request_body) + encoded_hmac = Base.encode64(hmac) + + # `:crypto.hash_equals/2` will raise an error if the signatures are not the + # same length. We avoid this by checking the length first. + if String.length(encoded_hmac) != String.length(signature) do + false + else + :crypto.hash_equals(encoded_hmac, signature) + end + end + + @doc """ + Sign payload with HMAC-SHA256 key. + """ + @spec sign(request_body(), hmac256_key()) :: signature() + def sign(payload, hmac256_key) when is_binary(payload) do + hmac = :crypto.mac(:hmac, :sha256, hmac256_key, payload) + Base.encode64(hmac) + end +end diff --git a/lib/docusign/webhook/handler.ex b/lib/docusign/webhook/handler.ex new file mode 100644 index 0000000..87b3b61 --- /dev/null +++ b/lib/docusign/webhook/handler.ex @@ -0,0 +1,9 @@ +defmodule DocuSign.Webhook.Handler do + @moduledoc """ + Webhook handler behaviour. + """ + + @type error_reason :: binary() | atom() + + @callback handle_webhook(map()) :: :ok | {:ok, any()} | :error | {:error, error_reason()} +end diff --git a/lib/docusign/webhook_plug.ex b/lib/docusign/webhook_plug.ex new file mode 100644 index 0000000..b4b2bac --- /dev/null +++ b/lib/docusign/webhook_plug.ex @@ -0,0 +1,242 @@ +defmodule DocuSign.WebhookPlug do + @moduledoc """ + Helper `Plug` to process webhook events and send them to a custom handler. + + Based on the excellent Stripe webhook plug: + https://github.com/beam-community/stripity-stripe/blob/v3.1.1/lib/stripe/webhook_plug.ex + + ## Installation + + To handle webhook events, you must first configure your application's endpoint. + Add the following to `endpoint.ex`, **before** `Plug.Parsers` is loaded. + + ```elixir + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSign.WebhookHandler, + hmac_secret_key: fn -> Application.get_env(:myapp, :hmac_secret_key) end + ``` + + If you have not yet added a webhook to your DocuSign account, you can do so + by visiting the 'Settings > Connect'. Use the route you configured in the + endpoint above and copy the HMAC secret key into your app's configuration. + + ### Supported options + + - `at`: The URL path your application should listen for DocuSign webhooks on. + Configure this to match whatever you set in the webhook. + - `handler`: Custom event handler module that accepts `map()` payloads + and processes them within your application. You must create this module. + - `secret`: Webhook HMAC secret obtained from the DocuSign Connect dashboard. + This can also be a function or a tuple for runtime configuration. + + ## Handling events + + You will need to create a custom event handler module to handle events. + + Your event handler module should implement the `DocuSign.Webhook.Handler` + behavior, defining a `handle_webhook/1` function which takes a `map()` + payload and returns either `{:ok, term}` or `:ok`. This will mark the event as + successfully processed. Alternatively handler can signal an error by returning + `:error` or `{:error, reason}` tuple, where reason is an atom or a string. + HTTP status code 400 will be used for errors. + + Refer to https://developers.docusign.com/platform/webhooks/connect/json-sim-event-reference/ + for the possible payloads you may receive. Note that these payloads can vary + based on your Connect configuration. + + ### Example + + ```elixir + # lib/myapp_web/docusign_handler.ex + + defmodule MyAppWeb.DocuSignHandler do + @behaviour DocuSign.Webhook.Handler + + @impl true + def handle_webhook(%{"event" => "envelope-completed"} = event) do + # TODO: handle the envelope-completed event + end + + @impl true + def handle_webhook(%{"event" => "envelope-discard"} = event) do + # TODO: handle the "envelope-discard" event + end + + # Return HTTP 200 for unhandled events + @impl true + def handle_webhook(_event), do: :ok + end + ``` + + ## Configuration + + You can configure the HMAC secret key in your app's own config file. + For example: + + ```elixir + config :myapp, + # [...] + hmac_secret_key: "AB123_******" + ``` + + You may then include the secret in your endpoint: + + ```elixir + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSign.WebhookHandler, + hmac_secret_key: Application.get_env(:myapp, :hmac_secret_key) + ``` + + ### Runtime configuration + + If you're loading config dynamically at runtime (eg with `runtime.exs` + or an OTP app) you must pass a tuple or function as the secret. + + ```elixir + # With a tuple + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSign.WebhookHandler, + secret: {Application, :get_env, [:myapp, :hmac_secret_key]} + + # Or, with a function + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSign.WebhookHandler, + secret: fn -> Application.get_env(:myapp, :hmac_secret_key) end + ``` + + ### HMAC secret key + + Only 1 HMAC secret key can be configured. It is assumed that to rotate + the HMAC secret key: + + 1. An additional HMAC secret key is added to the DocuSign Connect + configuration. + 2. The HMAC secret key of the plug is updated to this new secret key. + 3. Finally the previous HMAC secret key is removed from the DocuSign Connect. + + ## HMAC signatures + + DocuSign can send up to 100 HMAC signatures, which would happen if you have + configured 100 HMAC secret keys in your Connect dashboard. Although this is + unlikely, the plug will check all of the signatures provided. + """ + + import Plug.Conn + alias Plug.Conn + + alias DocuSign.Webhook.Crypto + + @behaviour Plug + + @impl true + def init(opts) do + path_info = String.split(opts[:at], "/", trim: true) + + opts + |> Enum.into(%{}) + |> Map.put_new(:path_info, path_info) + end + + # sobelow_skip ["XSS"] + # `send_resp(conn, 400, reason)` is controlled by the handler module. It's not user input. + @impl true + def call( + %Conn{method: "POST", path_info: path_info} = conn, + %{ + path_info: path_info, + hmac_secret_key: hmac_secret_key, + handler: handler + } + ) do + secret = parse_secret!(hmac_secret_key) + {:ok, payload, conn} = Conn.read_body(conn) + + with :ok <- verify_signatures(payload, secret, signatures(conn)), + {:ok, %{} = event} <- parse_payload(payload), + :ok <- handle_event!(handler, event) do + halt(send_resp(conn, 200, "Webhook received.")) + else + {:handle_error, reason} -> halt(send_resp(conn, 400, reason)) + _ -> halt(send_resp(conn, 400, "Bad request.")) + end + end + + @impl true + def call(%Conn{path_info: path_info} = conn, %{path_info: path_info}) do + halt(send_resp(conn, 400, "Bad request.")) + end + + @impl true + def call(conn, _), do: conn + + defp parse_secret!({m, f, a}), do: apply(m, f, a) + defp parse_secret!(fun) when is_function(fun), do: fun.() + defp parse_secret!(secret) when is_binary(secret), do: secret + + defp parse_secret!(secret) do + raise RuntimeError, """ + The DocuSign HMAC secret is invalid. Expected a string, tuple, or function. + Got: #{inspect(secret)} + + If you're setting the secret at runtime, you need to pass a tuple or function. + For example: + + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSignHandler, + secret: {Application, :get_env, [:myapp, :docusign_hmac_secret]} + """ + end + + defp signatures(conn) do + Enum.flat_map(1..100, fn index -> get_req_header(conn, "x-docusign-signature-#{index}") end) + end + + defp verify_signatures(payload, hmac_secret_key, signatures) do + Enum.reduce_while(signatures, {:error, :no_matching_signatures}, fn signature, error_result -> + if Crypto.verify_hmac(hmac_secret_key, payload, signature) do + {:halt, :ok} + else + {:cont, error_result} + end + end) + end + + defp parse_payload(payload) do + case Jason.decode(payload) do + {:ok, payload} -> {:ok, payload} + {:error, _} -> {:error, "Invalid JSON payload."} + end + end + + defp handle_event!(handler, payload) do + case handler.handle_webhook(payload) do + :ok -> + :ok + + {:ok, _} -> + :ok + + {:error, reason} when is_binary(reason) -> + {:handle_error, reason} + + {:error, reason} when is_atom(reason) -> + {:handle_error, Atom.to_string(reason)} + + :error -> + {:handle_error, ""} + + resp -> + raise RuntimeError, """ + #{inspect(handler)}.handle_webhook/1 returned an invalid response. Expected {:ok, term}, :ok, {:error, reason} or :error + Got: #{inspect(resp)} + + Event data: #{inspect(payload)} + """ + end + end +end diff --git a/test/docusign/webhook_plug_test.exs b/test/docusign/webhook_plug_test.exs new file mode 100644 index 0000000..b3187ae --- /dev/null +++ b/test/docusign/webhook_plug_test.exs @@ -0,0 +1,309 @@ +defmodule DocuSign.WebhookPlugTest do + use ExUnit.Case, async: true + use Plug.Test + + alias DocuSign.Webhook.Crypto + alias DocuSign.WebhookPlug + + @hmac256_key "sample-hmac-key" + + @webhook_event %{ + "event" => "envelope-completed", + "apiVersion" => "v2.1", + "uri" => + "/restapi/v2.1/accounts/b123a4e6-094f-43aa-b2d9-30076d0af3c7/envelopes/6b0cff6d-1def-40c3-9684-1587c8fcaa2c", + "retryCount" => 0, + "configurationId" => 123_456, + "generatedDateTime" => "2024-02-13T14:34:42.2740396Z", + "data" => %{ + "accountId" => "b123a4e6-094f-43aa-b2d9-30076d0af3c7", + "userId" => "861d2a78-7e55-42a1-ba7f-7501cc1a0d28", + "envelopeId" => "6b0cff6d-1def-40c3-9684-1587c8fcaa2c" + } + } + + @opts WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.StubHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_mfa_secret WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.StubHandler, + hmac_secret_key: {__MODULE__, :docusign_hmac_secret, [:arg1, :arg2]} + ) + + @opts_with_send_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.SendHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_tuple_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.StubTupleHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_error_atom_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.ErrorAtomHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_error_atom_reason_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.ErrorAtomReasonHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_error_string_reason_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.ErrorStringReasonHandler, + hmac_secret_key: "sample-hmac-key" + ) + + @opts_with_bad_handler WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.BadHandler, + hmac_secret_key: "sample-hmac-key" + ) + + defmodule StubHandler do + @moduledoc """ + Webhook handler that always returns ok. + """ + + def handle_webhook(_payload), do: :ok + end + + defmodule StubTupleHandler do + @moduledoc """ + Webhook handler that always returns ok tuple. + """ + + def handle_webhook(_payload), do: {:ok, %{meta: true}} + end + + defmodule SendHandler do + @moduledoc """ + Webhook handler that sends event to current process. + """ + + def handle_webhook(payload) do + send(self(), {:webhook_event, payload}) + :ok + end + end + + defmodule ErrorAtomHandler do + @moduledoc """ + Webhook handler that always returns :error. + """ + + def handle_webhook(_payload), do: :error + end + + defmodule ErrorAtomReasonHandler do + @moduledoc """ + Webhook handler that always returns `{:error, :timeout}`. + """ + + def handle_webhook(_payload), do: {:error, :timeout} + end + + defmodule ErrorStringReasonHandler do + @moduledoc """ + Webhook handler that always returns `{:error, "Unable to process webhook right now."}`. + """ + + def handle_webhook(_payload), do: {:error, "Unable to process webhook right now."} + end + + defmodule BadHandler do + @moduledoc """ + Handler that does not implement handler behaviour. + """ + + def handle_webhook(_payload), do: :timeout + end + + describe "WebhookPlug" do + test "passes webhook event to handler" do + conn = build_request(@webhook_event, @hmac256_key) + WebhookPlug.call(conn, @opts_with_send_handler) + assert_received {:webhook_event, @webhook_event} + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "returns 200 if handler returns ok tuple" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts_with_tuple_handler) + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "returns 400 if handler returns :error" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts_with_error_atom_handler) + assert {400, _, ""} = sent_resp(conn) + end + + test "returns 400 with reason if handler returns error with atom reason" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts_with_error_atom_reason_handler) + assert {400, _, "timeout"} = sent_resp(conn) + end + + test "returns 400 with reason if handler returns error with string reason" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts_with_error_string_reason_handler) + assert {400, _, "Unable to process webhook right now."} = sent_resp(conn) + end + + test "crash hard if handler fails" do + conn = build_request(@webhook_event, @hmac256_key) + + expected_exception_message = """ + DocuSign.WebhookPlugTest.BadHandler.handle_webhook/1 returned an invalid response. Expected {:ok, term}, :ok, {:error, reason} or :error + Got: :timeout + + Event data: #{inspect(@webhook_event)} + """ + + assert_raise RuntimeError, expected_exception_message, fn -> + WebhookPlug.call(conn, @opts_with_bad_handler) + end + end + + test "accepts valid signature" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts) + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "accepts multiple signatures if one signature is valid" do + conn = build_request(@webhook_event, ["different-key", @hmac256_key, "another-key"]) + conn = WebhookPlug.call(conn, @opts) + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "rejects invalid signature" do + conn = build_request(@webhook_event, "not-the-right-key") + conn = WebhookPlug.call(conn, @opts) + assert {400, _, "Bad request."} = sent_resp(conn) + end + + test "rejects request with multiple signatures that are all invalid" do + conn = build_request(@webhook_event, ["not-the-right-key", "still-not-right"]) + conn = WebhookPlug.call(conn, @opts) + assert {400, _, "Bad request."} = sent_resp(conn) + end + + test "rejects request without signature" do + conn = build_request(@webhook_event, []) + conn = WebhookPlug.call(conn, @opts) + assert {400, _, "Bad request."} = sent_resp(conn) + end + + test "rejects non-JSON payloads" do + conn = build_request("

not JSON for sure

", @hmac256_key) + conn = WebhookPlug.call(conn, @opts) + assert {400, _, "Bad request."} = sent_resp(conn) + end + + test "ignores requests for other paths" do + conn = conn(:post, "/home") + conn = WebhookPlug.call(conn, @opts) + refute conn.status + end + + test "rejects non-POST requests" do + payload = Jason.encode!(@webhook_event) + + conn = + conn(:get, "/webhook/docusign", payload) + |> put_req_header("content-type", "application/json") + |> sign_request(payload, [@hmac256_key]) + + conn = WebhookPlug.call(conn, @opts) + assert {400, _, "Bad request."} = sent_resp(conn) + end + + test "accepts secret from mfa tuple" do + conn = build_request(@webhook_event, @hmac256_key) + conn = WebhookPlug.call(conn, @opts_with_mfa_secret) + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "accepts secret from function" do + conn = build_request(@webhook_event, @hmac256_key) + + opts = + WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.StubHandler, + hmac_secret_key: fn -> @hmac256_key end + ) + + conn = WebhookPlug.call(conn, opts) + assert {200, _, "Webhook received."} = sent_resp(conn) + end + + test "raises on invalid HMAC secret" do + conn = build_request(@webhook_event, @hmac256_key) + + opts = + WebhookPlug.init( + at: "/webhook/docusign", + handler: __MODULE__.StubHandler, + hmac_secret_key: 12_345 + ) + + expected_exception_message = """ + The DocuSign HMAC secret is invalid. Expected a string, tuple, or function. + Got: 12345 + + If you're setting the secret at runtime, you need to pass a tuple or function. + For example: + + plug DocuSign.WebhookPlug, + at: "/webhook/docusign", + handler: MyAppWeb.DocuSignHandler, + secret: {Application, :get_env, [:myapp, :docusign_hmac_secret]} + """ + + assert_raise RuntimeError, expected_exception_message, fn -> + WebhookPlug.call(conn, opts) + end + end + end + + defp build_request(payload, hmac256_key_or_keys) when is_map(payload) do + build_request(Jason.encode!(payload), hmac256_key_or_keys) + end + + defp build_request(payload, hmac256_key_or_keys) when is_binary(payload) do + conn(:post, "/webhook/docusign", payload) + |> put_req_header("content-type", "application/json") + |> sign_request(payload, List.wrap(hmac256_key_or_keys)) + end + + defp sign_request(conn, payload, hmac256_key_or_keys) do + for {key, index} <- Enum.with_index(hmac256_key_or_keys), reduce: conn do + conn -> + one_based_index = index + 1 + + put_req_header( + conn, + "x-docusign-signature-#{one_based_index}", + Crypto.sign(payload, key) + ) + end + end + + # See @opts_with_mfa_secret + def docusign_hmac_secret(:arg1, :arg2) do + @hmac256_key + end +end