diff --git a/lib/workos/webhooks/webhooks.ex b/lib/workos/webhooks.ex similarity index 96% rename from lib/workos/webhooks/webhooks.ex rename to lib/workos/webhooks.ex index dbb38d7d..cc73a6b7 100644 --- a/lib/workos/webhooks/webhooks.ex +++ b/lib/workos/webhooks.ex @@ -1,10 +1,10 @@ defmodule WorkOS.Webhooks do @moduledoc """ - The Webhooks module provides convenience methods for working with WorkOS webhooks. - Creates a WorkOS Webhook Event from the webhook's payload if signature is valid. + Manage timestamp and signature validation of Webhooks in WorkOS. See https://workos.com/docs/webhooks """ + alias WorkOS.Webhooks.Event @three_minute_default_tolerance 60 * 3 diff --git a/lib/workos/webhooks/event.ex b/lib/workos/webhooks/event.ex index df8eead1..35f65ac8 100644 --- a/lib/workos/webhooks/event.ex +++ b/lib/workos/webhooks/event.ex @@ -2,6 +2,7 @@ defmodule WorkOS.Webhooks.Event do @moduledoc """ Module to represent a webhook event """ + defstruct [:id, :event, :data] @spec new(payload :: String.t()) :: __MODULE__ diff --git a/mix.exs b/mix.exs index b4506f64..40eac881 100755 --- a/mix.exs +++ b/mix.exs @@ -86,6 +86,7 @@ defmodule WorkOS.MixProject do WorkOS.SSO, WorkOS.Organizations, WorkOS.Portal, + WorkOS.Webhooks, ], "Response Structs": [ WorkOS.SSO.Connection, @@ -95,6 +96,7 @@ defmodule WorkOS.MixProject do WorkOS.Organizations.Organization, WorkOS.Organizations.Organization.Domain, WorkOS.Portal.Link, + WorkOS.Webhooks.Event, WorkOS.Empty, WorkOS.Error, WorkOS.List diff --git a/test/workos/webhooks_test.exs b/test/workos/webhooks_test.exs new file mode 100644 index 00000000..1b582c95 --- /dev/null +++ b/test/workos/webhooks_test.exs @@ -0,0 +1,149 @@ +defmodule WorkOS.WebhooksTest do + use ExUnit.Case + + alias WorkOS.Webhooks + + @secret "secret" + @timestamp DateTime.utc_now() |> DateTime.add(30) |> DateTime.to_unix(:millisecond) + @payload """ + {"id": "wh_123","data":{"id":"directory_user_01FAEAJCR3ZBZ30D8BD1924TVG","state":"active","emails":[{"type":"work","value":"blair@foo-corp.com","primary":true}],"idp_id":"00u1e8mutl6wlH3lL4x7","object":"directory_user","username":"blair@foo-corp.com","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":"blair@foo-corp.com","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":"blair@foo-corp.com","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"} + """ + @unhashed_string "#{@timestamp}.#{@payload}" + @signature_hash :crypto.mac(:hmac, :sha256, @secret, @unhashed_string) + |> Base.encode16(case: :lower) + @expectated_data_map %{ + id: "directory_user_01FAEAJCR3ZBZ30D8BD1924TVG", + state: "active", + emails: [ + %{ + type: "work", + value: "blair@foo-corp.com", + primary: true + } + ], + idp_id: "00u1e8mutl6wlH3lL4x7", + object: "directory_user", + username: "blair@foo-corp.com", + 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: "blair@foo-corp.com", + 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: "blair@foo-corp.com", + 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" + } + } + } + + describe "construct_event/4 - valid inputs" do + setup do + %{sig_header: "t=#{@timestamp}, v1=#{@signature_hash}"} + end + + test "returns a webhook event with a valid payload, sig_header, and secret", %{ + sig_header: sig_header + } do + {:ok, %WorkOS.Webhooks.Event{} = webhook} = + Webhooks.construct_event(@payload, sig_header, @secret) + + assert webhook.data == @expectated_data_map + assert webhook.event == "dsync.user.created" + assert webhook.id == "wh_123" + end + + test "returns a webhook event with a valid payload, sig_header, secret, and tolerance", %{ + sig_header: sig_header + } do + {:ok, webhook} = Webhooks.construct_event(@payload, sig_header, @secret, 100) + + assert webhook.data == @expectated_data_map + assert webhook.event == "dsync.user.created" + assert webhook.id == "wh_123" + end + end + + describe "construct_event/4 - invalid inputs" do + setup do + %{sig_header: "t=#{@timestamp}, v1=#{@signature_hash}"} + end + + test "returns an error with an empty header" do + empty_sig_header = "" + + assert {:error, "Signature or timestamp missing"} == + Webhooks.construct_event(@payload, empty_sig_header, @secret) + end + + test "returns an error with an empty signature hash" do + missing_sig_hash = "t=#{@timestamp}, v1=" + + assert {:error, "Signature or timestamp missing"} == + Webhooks.construct_event(@payload, missing_sig_hash, @secret) + end + + test "returns an error with an incorrect signature hash" do + incorrect_sig_hash = "t=#{@timestamp}, v1=99999" + + assert {:error, "Signature hash does not match the expected signature hash for payload"} == + Webhooks.construct_event(@payload, incorrect_sig_hash, @secret) + end + + test "returns an error with an incorrect payload", %{sig_header: sig_header} do + invalid_payload = "invalid" + + assert {:error, "Signature hash does not match the expected signature hash for payload"} == + Webhooks.construct_event(invalid_payload, sig_header, @secret) + end + + test "returns an error with an incorrect secret", %{sig_header: sig_header} do + invalid_secret = "invalid" + + assert {:error, "Signature hash does not match the expected signature hash for payload"} == + Webhooks.construct_event(@payload, sig_header, invalid_secret) + end + + test "returns an error with a timestamp outside tolerance" do + sig_header = "t=9999, v1=#{@signature_hash}" + + assert {:error, "Timestamp outside the tolerance zone"} == + Webhooks.construct_event(@payload, sig_header, @secret) + end + end +end