|
| 1 | +defmodule WorkOS.Webhooks do |
| 2 | + @moduledoc """ |
| 3 | + The Webhooks module provides convenience methods for working with WorkOS webhooks. |
| 4 | + Creates a WorkOS Webhook Event from the webhook's payload if signature is valid. |
| 5 | +
|
| 6 | + See https://workos.com/docs/webhooks |
| 7 | + """ |
| 8 | + alias WorkOS.Webhooks.Event |
| 9 | + @three_minute_default_tolerance 60 * 3 |
| 10 | + |
| 11 | + @doc """ |
| 12 | + Verify webhook payload and return an event. |
| 13 | +
|
| 14 | + `payload` is the raw, unparsed content body sent by WorkOS, which can be |
| 15 | + retrieved with `Plug.Conn.read_body/2`, Note that `Plug.Parsers` will read |
| 16 | + and discard the body, so you must implement a [custom body reader][1] if the |
| 17 | + plug is located earlier in the pipeline. |
| 18 | +
|
| 19 | + `sigHeader` is the value of `workos-signature` header, which can be fetched |
| 20 | + with `Plug.Conn.get_req_header/2`, i.e. `Plug.Conn.get_req_header(conn, "workos-signature")`. |
| 21 | +
|
| 22 | + `secret` is your webhook endpoint's secret from the WorkOS Dashboard. |
| 23 | +
|
| 24 | + `tolerance` is the allowed deviation in seconds from the current system time |
| 25 | + to the timestamp found in `signature`. Defaults to 180 seconds (3 minutes). |
| 26 | +
|
| 27 | + WorkOS API reference: |
| 28 | + https://workos.com/docs/webhooks/securing-your-webhook-endpoint/validating-events-are-from-workos |
| 29 | +
|
| 30 | + [1]: https://hexdocs.pm/plug/Plug.Parsers.html#module-custom-body-reader |
| 31 | +
|
| 32 | + ## Example plug in your consuming application which places the constructed |
| 33 | + event in the conn assigns |
| 34 | +
|
| 35 | + defmodule MyAppWeb.WorkOSWebhooksPlug do |
| 36 | + @behaviour Plug |
| 37 | +
|
| 38 | + alias Plug.Conn |
| 39 | +
|
| 40 | + def init(config), do: config |
| 41 | +
|
| 42 | + # Match on any requests from workos, i.e. the Endpoint URL configured in |
| 43 | + # the WorkOS Dashboard, adjust @request_path as appropriate |
| 44 | + @request_path "/webhooks/workos" |
| 45 | + def call(%{request_path: @request_path} = conn, _) do |
| 46 | + signing_secret = Application.get_env(:workos, :webhook_signing_secret) |
| 47 | + [worksos_signature] = Conn.get_req_header(conn, "workos-signature") |
| 48 | +
|
| 49 | + with {:ok, body, _} <- Conn.read_body(conn), |
| 50 | + {:ok, workos_event} <- |
| 51 | + WorkOS.Webhooks.construct_event(body, worksos_signature, signing_secret) do |
| 52 | + Conn.assign(conn, :workos_event, workos_event) |
| 53 | + else |
| 54 | + {:error, error} -> |
| 55 | + conn |
| 56 | + |> Conn.send_resp(:bad_request, error.message) |
| 57 | + |> Conn.halt() |
| 58 | + end |
| 59 | + end |
| 60 | +
|
| 61 | + def call(conn, _), do: conn |
| 62 | + end |
| 63 | +
|
| 64 | + As per the aforementioned note about `Plug.Parsers` the above plug would need to |
| 65 | + precede these in Endpoint.ex ... i.e. |
| 66 | +
|
| 67 | + defmodule MyAppWeb.Endpoint do |
| 68 | + ... |
| 69 | + ... |
| 70 | +
|
| 71 | + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] |
| 72 | +
|
| 73 | + plug MyAppWeb.WorkOSWebhooksPlug |
| 74 | +
|
| 75 | + plug Plug.Parsers, |
| 76 | + parsers: [:urlencoded, :multipart, :json], |
| 77 | + pass: ["*/*"], |
| 78 | + json_decoder: Phoenix.json_library() |
| 79 | + ... |
| 80 | + ... |
| 81 | + """ |
| 82 | + @spec construct_event( |
| 83 | + payload :: String.t(), |
| 84 | + sigHeader :: String.t(), |
| 85 | + secret :: String.t(), |
| 86 | + tolerance :: pos_integer() |
| 87 | + ) :: {:ok, payload :: map()} | {:error, message :: String.t()} |
| 88 | + def construct_event(payload, sigHeader, secret, tolerance \\ @three_minute_default_tolerance) do |
| 89 | + with {:ok, %{timestamp: timestamp, expected_signature_hash: expected_signature_hash}} <- |
| 90 | + get_timestamp_and_expected_signature_hash(sigHeader), |
| 91 | + :ok <- verify_time_tolerance(timestamp, tolerance), |
| 92 | + {:ok, computed_signature_hash} <- compute_signature(timestamp, payload, secret), |
| 93 | + :ok <- compare_signatures(computed_signature_hash, expected_signature_hash) do |
| 94 | + {:ok, payload |> Jason.decode!() |> Event.new()} |
| 95 | + end |
| 96 | + end |
| 97 | + |
| 98 | + defp compare_signatures(computed_signature_hash, expected_signature_hash) do |
| 99 | + if Plug.Crypto.secure_compare(computed_signature_hash, expected_signature_hash) do |
| 100 | + :ok |
| 101 | + else |
| 102 | + {:error, "Signature hash does not match the expected signature hash for payload"} |
| 103 | + end |
| 104 | + end |
| 105 | + |
| 106 | + defp compute_signature(timestamp, payload, secret) do |
| 107 | + unhashed_string = "#{timestamp}.#{payload}" |
| 108 | + |
| 109 | + computed_signature = |
| 110 | + :crypto.mac(:hmac, :sha256, secret, unhashed_string) |
| 111 | + |> Base.encode16(case: :lower) |
| 112 | + |
| 113 | + {:ok, computed_signature} |
| 114 | + end |
| 115 | + |
| 116 | + defp verify_time_tolerance(timestamp, tolerance) do |
| 117 | + timestamp_date = DateTime.from_unix!(timestamp, :millisecond) |
| 118 | + |
| 119 | + latest_allowed_date = DateTime.utc_now() |> DateTime.add(tolerance * -1) |
| 120 | + |
| 121 | + if DateTime.compare(timestamp_date, latest_allowed_date) == :gt do |
| 122 | + :ok |
| 123 | + else |
| 124 | + {:error, "Timestamp outside the tolerance zone"} |
| 125 | + end |
| 126 | + end |
| 127 | + |
| 128 | + @expected_scheme "v1" |
| 129 | + defp get_timestamp_and_expected_signature_hash(signature) do |
| 130 | + parsed = |
| 131 | + for pair <- String.split(signature, ","), |
| 132 | + destructure([key, value], String.split(pair, "=", parts: 2)), |
| 133 | + do: {key |> String.trim(), value}, |
| 134 | + into: %{} |
| 135 | + |
| 136 | + with %{"t" => timestamp, @expected_scheme => signature_hash} <- parsed, |
| 137 | + {timestamp, ""} <- Integer.parse(timestamp), |
| 138 | + true <- String.length(signature_hash) > 0 do |
| 139 | + {:ok, %{timestamp: timestamp, expected_signature_hash: signature_hash}} |
| 140 | + else |
| 141 | + _ -> {:error, "Signature or timestamp missing"} |
| 142 | + end |
| 143 | + end |
| 144 | +end |
0 commit comments