-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add webhook plug based on Stripe equivalent
- Loading branch information
1 parent
b5790ef
commit 4e1ffa0
Showing
4 changed files
with
595 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Oops, something went wrong.