diff --git a/lib/workos/action_context.ex b/lib/workos/action_context.ex new file mode 100644 index 0000000..88c65d9 --- /dev/null +++ b/lib/workos/action_context.ex @@ -0,0 +1,67 @@ +# @oagen-ignore-file — hand-maintained; AuthKit Actions request context. + +defmodule WorkOS.ActionContext do + @moduledoc """ + A verified, deserialized AuthKit Action request. + + WorkOS sends a flat context object discriminated by `object`, not the + webhook event envelope: + + * `"authentication_action_context"` — `user`, `organization`, + `organization_membership`, `issuer` + * `"user_registration_action_context"` — `user_data`, `invitation` + + `ip_address`, `user_agent`, and `device_fingerprint` are shared by both + context types; fields specific to the other variant are `nil`. + """ + + defstruct [ + :object, + :id, + :ip_address, + :user_agent, + :device_fingerprint, + :user, + :organization, + :organization_membership, + :issuer, + :user_data, + :invitation + ] + + @type t :: %__MODULE__{ + object: String.t() | nil, + id: String.t() | nil, + ip_address: String.t() | nil, + user_agent: String.t() | nil, + device_fingerprint: String.t() | nil, + user: WorkOS.User.t() | nil, + organization: WorkOS.Organization.t() | nil, + organization_membership: WorkOS.OrganizationMembership.t() | nil, + issuer: String.t() | nil, + user_data: WorkOS.ActionUserData.t() | nil, + invitation: WorkOS.Invitation.t() | nil + } + + @doc false + @spec from_map(map()) :: t() + def from_map(map) when is_map(map) do + %__MODULE__{ + object: map["object"], + id: map["id"], + ip_address: map["ip_address"], + user_agent: map["user_agent"], + device_fingerprint: map["device_fingerprint"], + user: WorkOS.Cast.nested(map["user"], &WorkOS.User.from_map/1), + organization: WorkOS.Cast.nested(map["organization"], &WorkOS.Organization.from_map/1), + organization_membership: + WorkOS.Cast.nested( + map["organization_membership"], + &WorkOS.OrganizationMembership.from_map/1 + ), + issuer: map["issuer"], + user_data: WorkOS.Cast.nested(map["user_data"], &WorkOS.ActionUserData.from_map/1), + invitation: WorkOS.Cast.nested(map["invitation"], &WorkOS.Invitation.from_map/1) + } + end +end diff --git a/lib/workos/action_user_data.ex b/lib/workos/action_user_data.ex new file mode 100644 index 0000000..dcbab6e --- /dev/null +++ b/lib/workos/action_user_data.ex @@ -0,0 +1,30 @@ +# @oagen-ignore-file — hand-maintained; AuthKit Actions user registration data. + +defmodule WorkOS.ActionUserData do + @moduledoc """ + The provisional user data carried by a `user_registration` action context + (object: `"user_data"`). + """ + + defstruct [:object, :email, :name, :first_name, :last_name] + + @type t :: %__MODULE__{ + object: String.t() | nil, + email: String.t() | nil, + name: String.t() | nil, + first_name: String.t() | nil, + last_name: String.t() | nil + } + + @doc false + @spec from_map(map()) :: t() + def from_map(map) when is_map(map) do + %__MODULE__{ + object: map["object"], + email: map["email"], + name: map["name"], + first_name: map["first_name"], + last_name: map["last_name"] + } + end +end diff --git a/lib/workos/actions.ex b/lib/workos/actions.ex index 7139d85..3198c30 100644 --- a/lib/workos/actions.ex +++ b/lib/workos/actions.ex @@ -14,13 +14,19 @@ defmodule WorkOS.Actions do secret ) - `response.payload` and `response.sig` form the action webhook response body. + `response.object`, `response.payload`, and `response.signature` form the + action webhook response body (`{object, payload, signature}`). """ alias WorkOS.Webhooks.Signature @default_tolerance_seconds 30 + @action_type_to_response_object %{ + "authentication" => "authentication_action_response", + "user_registration" => "user_registration_action_response" + } + @typedoc """ Options for verification and signing. @@ -46,15 +52,15 @@ defmodule WorkOS.Actions do end @doc """ - Verifies and deserializes an Actions request into a `WorkOS.EventSchema`. + Verifies and deserializes an Actions request into a `WorkOS.ActionContext`. """ @spec construct_action(String.t(), String.t(), String.t(), [option()]) :: - {:ok, WorkOS.EventSchema.t()} + {:ok, WorkOS.ActionContext.t()} | {:error, WorkOS.Webhooks.Signature.verification_error()} def construct_action(payload, sig_header, secret, opts \\ []) do with :ok <- verify_header(payload, sig_header, secret, opts) do case JSON.decode(payload) do - {:ok, decoded} when is_map(decoded) -> {:ok, WorkOS.EventSchema.from_map(decoded)} + {:ok, decoded} when is_map(decoded) -> {:ok, WorkOS.ActionContext.from_map(decoded)} _ -> {:error, :invalid_json} end end @@ -72,23 +78,27 @@ defmodule WorkOS.Actions do Returns `{:ok, %{payload: base64_payload, sig: "t=,v1="}}`. """ @spec sign_response(map(), String.t(), [option()]) :: - {:ok, %{payload: String.t(), sig: String.t()}} + {:ok, %{object: String.t(), payload: map(), signature: String.t()}} def sign_response(response, secret, opts \\ []) do now_ms = Keyword.get_lazy(opts, :now_ms, fn -> System.system_time(:millisecond) end) + type = to_string(Map.get(response, :type) || Map.get(response, "type")) + verdict = to_string(Map.get(response, :verdict) || Map.get(response, "verdict")) + error_message = Map.get(response, :error_message) || Map.get(response, "error_message") + + object = Map.fetch!(@action_type_to_response_object, type) + payload = - %{ - "type" => to_string(Map.get(response, :type) || Map.get(response, "type")), - "verdict" => to_string(Map.get(response, :verdict) || Map.get(response, "verdict")), - "error_message" => - to_string(Map.get(response, :error_message) || Map.get(response, "error_message") || "") - } - |> JSON.encode!() - |> Base.encode64() + if verdict == "Deny" and is_binary(error_message) and error_message != "" do + %{"timestamp" => now_ms, "verdict" => verdict, "error_message" => error_message} + else + %{"timestamp" => now_ms, "verdict" => verdict} + end timestamp = Integer.to_string(now_ms) - signature = Signature.compute_signature(secret, timestamp, payload) + payload_json = JSON.encode!(payload) + signature = Signature.compute_signature(secret, timestamp, payload_json) - {:ok, %{payload: payload, sig: "t=#{timestamp},v1=#{signature}"}} + {:ok, %{object: object, payload: payload, signature: signature}} end end diff --git a/test/workos/actions_test.exs b/test/workos/actions_test.exs index c6f4a16..38527b5 100644 --- a/test/workos/actions_test.exs +++ b/test/workos/actions_test.exs @@ -6,7 +6,7 @@ defmodule WorkOS.ActionsTest do alias WorkOS.Webhooks.Signature @secret "actions_secret_123" - @payload ~s({"id":"event_123","event":"authentication.action","data":{"user":{"id":"user_123"}},"created_at":"2026-01-01T00:00:00.000Z"}) + @payload ~s({"object":"authentication_action_context","id":"action_01","user":{"object":"user","id":"user_01","email":"test@example.com"},"ip_address":"1.2.3.4","device_fingerprint":"fp_123","issuer":"https://auth.example.com"}) defp signed_header(payload, now_ms) do timestamp = Integer.to_string(now_ms) @@ -36,40 +36,70 @@ defmodule WorkOS.ActionsTest do assert :ok = WorkOS.Actions.verify_header(@payload, header, @secret, tolerance: 3600) end - test "construct_action verifies and deserializes the request" do + test "construct_action verifies and deserializes an authentication request" do header = signed_header(@payload, System.system_time(:millisecond)) - assert {:ok, %WorkOS.EventSchema{} = action} = + assert {:ok, %WorkOS.ActionContext{} = action} = WorkOS.Actions.construct_action(@payload, header, @secret) - assert action.event == "authentication.action" - assert action.data["user"]["id"] == "user_123" + assert action.object == "authentication_action_context" + assert action.id == "action_01" + assert action.user.id == "user_01" + assert action.user.email == "test@example.com" + assert action.ip_address == "1.2.3.4" + assert action.issuer == "https://auth.example.com" end - test "sign_response produces a payload the verifier accepts" do - assert {:ok, %{payload: payload, sig: sig}} = + test "construct_action deserializes a user_registration request" do + payload = + ~s({"object":"user_registration_action_context","id":"action_02","user_data":{"object":"user_data","email":"new@example.com","first_name":"New","last_name":"User","name":null},"ip_address":"5.6.7.8","device_fingerprint":"fp_456"}) + + header = signed_header(payload, System.system_time(:millisecond)) + + assert {:ok, %WorkOS.ActionContext{} = action} = + WorkOS.Actions.construct_action(payload, header, @secret) + + assert action.object == "user_registration_action_context" + assert action.user_data.email == "new@example.com" + assert action.user_data.first_name == "New" + assert action.user == nil + end + + test "sign_response produces a workos-node-compatible Allow response" do + now_ms = 1_700_000_000_000 + + assert {:ok, %{object: object, payload: payload, signature: signature}} = WorkOS.Actions.sign_response( %{type: "authentication", verdict: "Allow"}, - @secret + @secret, + now_ms: now_ms ) - decoded = payload |> Base.decode64!() |> JSON.decode!() - assert decoded["type"] == "authentication" - assert decoded["verdict"] == "Allow" + assert object == "authentication_action_response" + assert payload == %{"timestamp" => now_ms, "verdict" => "Allow"} + + expected = + Signature.compute_signature(@secret, Integer.to_string(now_ms), JSON.encode!(payload)) - # The signature is over the base64 payload, in the standard header format. - assert :ok = WorkOS.Actions.verify_header(payload, sig, @secret) + assert signature == expected end test "sign_response carries a deny error message" do - assert {:ok, %{payload: payload}} = + now_ms = 1_700_000_000_000 + + assert {:ok, %{object: object, payload: payload, signature: signature}} = WorkOS.Actions.sign_response( %{type: "user_registration", verdict: "Deny", error_message: "Blocked"}, - @secret + @secret, + now_ms: now_ms ) - decoded = payload |> Base.decode64!() |> JSON.decode!() - assert decoded["verdict"] == "Deny" - assert decoded["error_message"] == "Blocked" + assert object == "user_registration_action_response" + assert payload == %{"timestamp" => now_ms, "verdict" => "Deny", "error_message" => "Blocked"} + + expected = + Signature.compute_signature(@secret, Integer.to_string(now_ms), JSON.encode!(payload)) + + assert signature == expected end end