Skip to content

Commit 6f7c1a4

Browse files
riebeeknjacobiajohnson
authored andcommitted
Add Webhooks module
1 parent 7040471 commit 6f7c1a4

File tree

5 files changed

+326
-0
lines changed

5 files changed

+326
-0
lines changed

lib/workos/webhooks/event.ex

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule WorkOS.Webhooks.Event do
2+
@moduledoc """
3+
Module to represent a webhook event
4+
"""
5+
defstruct [:id, :event, :data]
6+
7+
@spec new(payload :: String.t()) :: __MODULE__
8+
def new(payload) do
9+
processed_map =
10+
[:id, :event, :data]
11+
|> Enum.reduce(%{}, fn key, acc ->
12+
string_key = to_string(key)
13+
converted_value = Map.get(payload, string_key) |> convert_value()
14+
Map.put(acc, key, converted_value)
15+
end)
16+
17+
struct(%__MODULE__{}, processed_map)
18+
end
19+
20+
defp convert_value(value) when is_map(value), do: convert_map(value)
21+
defp convert_value(value) when is_list(value), do: convert_list(value)
22+
defp convert_value(value), do: value
23+
24+
defp convert_map(value) do
25+
Enum.reduce(value, %{}, fn {key, value}, acc ->
26+
Map.put(acc, String.to_atom(key), convert_value(value))
27+
end)
28+
end
29+
30+
defp convert_list(list), do: list |> Enum.map(&convert_value/1)
31+
end

lib/workos/webhooks/webhooks.ex

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ defmodule WorkOS.MixProject do
2929
{:tesla, "~> 1.4"},
3030
{:hackney, "~> 1.18.0"},
3131
{:jason, ">= 1.0.0"},
32+
{:plug_crypto, "~> 1.0"},
3233
{:ex_doc, "~> 0.23", only: :dev, runtime: false},
3334
{:credo, "~> 1.6", only: [:dev, :test], runtime: false}
3435
]

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
1717
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
1818
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
19+
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
1920
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
2021
"tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"},
2122
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
defmodule WorkOS.WebhooksTest do
2+
use ExUnit.Case
3+
4+
alias WorkOS.Webhooks
5+
6+
@secret "secret"
7+
@timestamp DateTime.utc_now() |> DateTime.add(30) |> DateTime.to_unix(:millisecond)
8+
@payload """
9+
{"id": "wh_123","data":{"id":"directory_user_01FAEAJCR3ZBZ30D8BD1924TVG","state":"active","emails":[{"type":"work","value":"[email protected]","primary":true}],"idp_id":"00u1e8mutl6wlH3lL4x7","object":"directory_user","username":"[email protected]","last_name":"Lunchford","first_name":"Blair","job_title":"Software Engineer","directory_id":"directory_01F9M7F68PZP8QXP8G7X5QRHS7","raw_attributes":{"name":{"givenName":"Blair","familyName":"Lunchford","middleName":"Elizabeth","honorificPrefix":"Ms."},"title":"Software Engineer","active":true,"emails":[{"type":"work","value":"[email protected]","primary":true}],"groups":[],"locale":"en-US","schemas":["urn:ietf:params:scim:schemas:core:2.0:User","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],"userName":"[email protected]","addresses":[{"region":"CA","primary":true,"locality":"San Francisco","postalCode":"94016"}],"externalId":"00u1e8mutl6wlH3lL4x7","displayName":"Blair Lunchford","urn:ietf:params:scim:schemas:extension:enterprise:2.0:User":{"manager":{"value":"2","displayName":"Kate Chapman"},"division":"Engineering","department":"Customer Success"}}},"event":"dsync.user.created"}
10+
"""
11+
@unhashed_string "#{@timestamp}.#{@payload}"
12+
@signature_hash :crypto.mac(:hmac, :sha256, @secret, @unhashed_string)
13+
|> Base.encode16(case: :lower)
14+
@expectated_data_map %{
15+
id: "directory_user_01FAEAJCR3ZBZ30D8BD1924TVG",
16+
state: "active",
17+
emails: [
18+
%{
19+
type: "work",
20+
21+
primary: true
22+
}
23+
],
24+
idp_id: "00u1e8mutl6wlH3lL4x7",
25+
object: "directory_user",
26+
username: "[email protected]",
27+
last_name: "Lunchford",
28+
first_name: "Blair",
29+
job_title: "Software Engineer",
30+
directory_id: "directory_01F9M7F68PZP8QXP8G7X5QRHS7",
31+
raw_attributes: %{
32+
name: %{
33+
givenName: "Blair",
34+
familyName: "Lunchford",
35+
middleName: "Elizabeth",
36+
honorificPrefix: "Ms."
37+
},
38+
title: "Software Engineer",
39+
active: true,
40+
emails: [
41+
%{
42+
type: "work",
43+
44+
primary: true
45+
}
46+
],
47+
groups: [],
48+
locale: "en-US",
49+
schemas: [
50+
"urn:ietf:params:scim:schemas:core:2.0:User",
51+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
52+
],
53+
userName: "[email protected]",
54+
addresses: [
55+
%{
56+
region: "CA",
57+
primary: true,
58+
locality: "San Francisco",
59+
postalCode: "94016"
60+
}
61+
],
62+
externalId: "00u1e8mutl6wlH3lL4x7",
63+
displayName: "Blair Lunchford",
64+
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": %{
65+
manager: %{
66+
value: "2",
67+
displayName: "Kate Chapman"
68+
},
69+
division: "Engineering",
70+
department: "Customer Success"
71+
}
72+
}
73+
}
74+
75+
describe "construct_event/4 - valid inputs" do
76+
setup do
77+
%{sig_header: "t=#{@timestamp}, v1=#{@signature_hash}"}
78+
end
79+
80+
test "returns a webhook event with a valid payload, sig_header, and secret", %{
81+
sig_header: sig_header
82+
} do
83+
{:ok, %WorkOS.Webhooks.Event{} = webhook} =
84+
Webhooks.construct_event(@payload, sig_header, @secret)
85+
86+
assert webhook.data == @expectated_data_map
87+
assert webhook.event == "dsync.user.created"
88+
assert webhook.id == "wh_123"
89+
end
90+
91+
test "returns a webhook event with a valid payload, sig_header, secret, and tolerance", %{
92+
sig_header: sig_header
93+
} do
94+
{:ok, webhook} = Webhooks.construct_event(@payload, sig_header, @secret, 100)
95+
96+
assert webhook.data == @expectated_data_map
97+
assert webhook.event == "dsync.user.created"
98+
assert webhook.id == "wh_123"
99+
end
100+
end
101+
102+
describe "construct_event/4 - invalid inputs" do
103+
setup do
104+
%{sig_header: "t=#{@timestamp}, v1=#{@signature_hash}"}
105+
end
106+
107+
test "returns an error with an empty header" do
108+
empty_sig_header = ""
109+
110+
assert {:error, "Signature or timestamp missing"} ==
111+
Webhooks.construct_event(@payload, empty_sig_header, @secret)
112+
end
113+
114+
test "returns an error with an empty signature hash" do
115+
missing_sig_hash = "t=#{@timestamp}, v1="
116+
117+
assert {:error, "Signature or timestamp missing"} ==
118+
Webhooks.construct_event(@payload, missing_sig_hash, @secret)
119+
end
120+
121+
test "returns an error with an incorrect signature hash" do
122+
incorrect_sig_hash = "t=#{@timestamp}, v1=99999"
123+
124+
assert {:error, "Signature hash does not match the expected signature hash for payload"} ==
125+
Webhooks.construct_event(@payload, incorrect_sig_hash, @secret)
126+
end
127+
128+
test "returns an error with an incorrect payload", %{sig_header: sig_header} do
129+
invalid_payload = "invalid"
130+
131+
assert {:error, "Signature hash does not match the expected signature hash for payload"} ==
132+
Webhooks.construct_event(invalid_payload, sig_header, @secret)
133+
end
134+
135+
test "returns an error with an incorrect secret", %{sig_header: sig_header} do
136+
invalid_secret = "invalid"
137+
138+
assert {:error, "Signature hash does not match the expected signature hash for payload"} ==
139+
Webhooks.construct_event(@payload, sig_header, invalid_secret)
140+
end
141+
142+
test "returns an error with a timestamp outside tolerance" do
143+
sig_header = "t=9999, v1=#{@signature_hash}"
144+
145+
assert {:error, "Timestamp outside the tolerance zone"} ==
146+
Webhooks.construct_event(@payload, sig_header, @secret)
147+
end
148+
end
149+
end

0 commit comments

Comments
 (0)