From 6de21c8b9c7c8edbeeeabefc741001a1afcd97a7 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 10 Aug 2026 12:29:59 -0400 Subject: [PATCH 1/2] fix: parse AuthKit Actions request into flat ActionContext construct_action/4 deserialized the verified action request into the generic EventSchema envelope ({object, id, event, data, created_at}). The real Actions wire format is a flat context object discriminated by 'object' (authentication_action_context / user_registration_action_context), so parsing it into EventSchema silently dropped every useful field (user, user_data, ip_address, device_fingerprint, issuer, ...) with no error. Replace the EventSchema return with a typed WorkOS.ActionContext (+ WorkOS.ActionUserData), mirroring workos-node's ActionContext and reusing the generated User, Organization, OrganizationMembership, and Invitation models for nested objects. Dispatch on :object to read the type-specific fields. Tests now exercise the real wire format for both context types; the prior test used a fabricated event envelope that never matched what WorkOS sends. --- lib/workos/action_context.ex | 67 ++++++++++++++++++++++++++++++++++ lib/workos/action_user_data.ex | 30 +++++++++++++++ lib/workos/actions.ex | 6 +-- test/workos/actions_test.exs | 29 ++++++++++++--- 4 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 lib/workos/action_context.ex create mode 100644 lib/workos/action_user_data.ex 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..950567a 100644 --- a/lib/workos/actions.ex +++ b/lib/workos/actions.ex @@ -46,15 +46,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 diff --git a/test/workos/actions_test.exs b/test/workos/actions_test.exs index c6f4a16..8584d6c 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,14 +36,33 @@ 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 "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 payload the verifier accepts" do From 397d9e6e5fc2fd7afad0270ea272a31aa1ed9b06 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 10 Aug 2026 14:28:53 -0400 Subject: [PATCH 2/2] fix: sign action response in workos-node wire format sign_response emitted a base64 {payload, sig} shape that no other SDK produces. The real Actions response wire format (workos-node + python, ruby, php, dotnet, kotlin) is {object, payload, signature}, where signature = HMAC-SHA256(secret, ".") and payload = {timestamp, verdict, error_message?}. Switch the return to %{object, payload, signature}, signing over JSON.encode!(payload) so the signed bytes match the transmitted payload bytes. error_message is included only on a Deny verdict with a non-empty message, matching workos-node. --- lib/workos/actions.ex | 34 +++++++++++++++++++++------------ test/workos/actions_test.exs | 37 +++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/lib/workos/actions.ex b/lib/workos/actions.ex index 950567a..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. @@ -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 8584d6c..38527b5 100644 --- a/test/workos/actions_test.exs +++ b/test/workos/actions_test.exs @@ -65,30 +65,41 @@ defmodule WorkOS.ActionsTest do assert action.user == nil end - test "sign_response produces a payload the verifier accepts" do - assert {:ok, %{payload: payload, sig: sig}} = + 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