Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions lib/workos/action_context.ex
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions lib/workos/action_user_data.ex
Original file line number Diff line number Diff line change
@@ -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
40 changes: 25 additions & 15 deletions lib/workos/actions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -72,23 +78,27 @@ defmodule WorkOS.Actions do
Returns `{:ok, %{payload: base64_payload, sig: "t=<ms>,v1=<hex>"}}`.
"""
@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
66 changes: 48 additions & 18 deletions test/workos/actions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Loading