Gaiia webhook: clean rewrite per exact docs, detailed logging on failure

This commit is contained in:
Graham McIntire 2026-02-14 16:57:49 -06:00
parent d09bfb88eb
commit f59db9565c

View file

@ -2,13 +2,21 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
@moduledoc """
Webhook endpoint for receiving real-time updates from Gaiia.
Gaiia signs webhooks with `X-Gaiia-Webhook-Signature` header using HMAC-SHA256.
Format: `t=<unix_timestamp>,v1=<hex_signature>`
Signed payload: `<timestamp>.<raw_body>`
Implements signature verification per Gaiia's webhook security docs:
https://app.gaiia.com/docs/webhooks/security
If a webhook_secret is configured AND the signature header is present,
we verify it. Otherwise we accept the request (allows setup before
configuring the secret in Gaiia).
## Gaiia's verification algorithm:
1. **Extract** timestamp (`t`) and signature (`v1`) from the
`X-Gaiia-Webhook-Signature` header. Format: `t=<unix>,v1=<hex>`
2. **Prepare** the signed payload: `<timestamp>.<raw_request_body>`
3. **Compute** HMAC-SHA256 of the signed payload using the endpoint's
secret key. Hex-encode the result.
4. **Compare** the computed signature with `v1` from the header.
Also check that the timestamp is within acceptable tolerance.
Route: POST /api/v1/webhooks/gaiia/:organization_id
"""
@ -21,20 +29,36 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
@max_age_seconds 300
# ── Main action ──
def create(conn, %{"organization_id" => organization_id}) do
with {:ok, integration} <- get_gaiia_integration(organization_id),
:ok <- maybe_verify_signature(conn, integration),
{:ok, event} <- get_event(conn.body_params) do
:ok <- verify_webhook(conn, integration) do
process_webhook(conn, organization_id)
end
end
defp process_webhook(conn, organization_id) do
event = Map.get(conn.body_params, "eventName") || Map.get(conn.body_params, "event")
if is_nil(event) do
Logger.warning("Gaiia webhook missing eventName: #{inspect(Map.keys(conn.body_params))}")
conn |> put_status(:bad_request) |> json(%{error: "Missing eventName field"})
else
data = Map.get(conn.body_params, "payload", Map.get(conn.body_params, "data", %{}))
entity_id = Map.get(conn.body_params, "objectId", Map.get(data, "id"))
payload = %{"entity_id" => entity_id, "data" => data}
Webhooks.process_event(organization_id, event, payload)
Webhooks.process_event(organization_id, event, %{
"entity_id" => entity_id,
"data" => data
})
json(conn, %{status: "ok"})
end
end
# ── Integration lookup ──
defp get_gaiia_integration(organization_id) do
case Integrations.get_integration(organization_id, "gaiia") do
{:ok, integration} -> {:ok, integration}
@ -42,93 +66,98 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
end
end
defp maybe_verify_signature(conn, integration) do
# ── Signature verification per Gaiia docs ──
defp verify_webhook(conn, integration) do
secret = get_in(integration.credentials, ["webhook_secret"])
sig_header = get_req_header(conn, "x-gaiia-webhook-signature")
sig_headers = Plug.Conn.get_req_header(conn, "x-gaiia-webhook-signature")
case {secret, sig_header} do
# No secret configured — accept without verification
{nil, _} ->
cond do
is_nil(secret) or secret == "" ->
:ok
{"", _} ->
sig_headers == [] ->
Logger.warning("Gaiia webhook: secret configured but no signature header received")
:ok
# Secret configured but no signature header — accept (Gaiia may not have secret set yet)
{_secret, []} ->
Logger.warning("Gaiia webhook received without signature header, skipping verification")
:ok
# Both present — verify (log failures but accept anyway)
{secret, [header]} ->
true ->
raw_body = conn.private[:raw_body] || ""
case verify_signature(header, raw_body, secret) do
:ok ->
Logger.info("Gaiia webhook signature verified successfully")
:ok
{:error, _} ->
Logger.warning(
"Gaiia webhook signature verification failed — accepting anyway. " <>
"Header: #{inspect(String.slice(header, 0, 40))}..., " <>
"Body length: #{byte_size(raw_body)}, " <>
"Secret length: #{byte_size(secret)}"
)
:ok
end
verify_gaiia_signature(hd(sig_headers), raw_body, secret)
end
end
defp verify_signature(header, raw_body, secret) do
with {:ok, timestamp, signature} <- parse_signature_header(header),
# Step 1: Parse the header to extract t and v1
# Step 2-4: Build signed payload, compute HMAC, compare
defp verify_gaiia_signature(header, raw_body, secret) do
with {:ok, timestamp, v1_signature} <- parse_signature_header(header),
:ok <- check_timestamp(timestamp) do
# Step 2: signedPayload = "<timestamp>.<raw_body>"
signed_payload = timestamp <> "." <> raw_body
computed =
# Step 3: HMAC-SHA256, hex-encoded lowercase
expected =
:hmac
|> :crypto.mac(:sha256, secret, signed_payload)
|> Base.encode16(case: :lower)
if Plug.Crypto.secure_compare(computed, signature) do
# Step 4: Constant-time comparison
if Plug.Crypto.secure_compare(expected, v1_signature) do
:ok
else
Logger.warning("Gaiia webhook signature mismatch")
Logger.warning(
"Gaiia webhook signature mismatch — " <>
"ts=#{timestamp} body_len=#{byte_size(raw_body)} " <>
"secret_len=#{byte_size(secret)} " <>
"expected=#{String.slice(expected, 0, 12)}" <>
"received=#{String.slice(v1_signature, 0, 12)}"
)
{:error, :invalid_signature}
end
else
{:error, :malformed} ->
Logger.warning("Gaiia webhook: malformed signature header: #{inspect(header)}")
{:error, :invalid_signature}
{:error, :expired} ->
Logger.warning("Gaiia webhook: timestamp expired (>#{@max_age_seconds}s old)")
{:error, :invalid_signature}
end
end
# Parse "t=1492774577,v1=5257a869..." into {:ok, "1492774577", "5257a869..."}
defp parse_signature_header(header) do
parts =
header
|> String.split(",")
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Map.new(fn [k, v] -> {String.trim(k), v} end)
|> Enum.reduce(%{}, fn element, acc ->
case String.split(element, "=", parts: 2) do
[key, value] -> Map.put(acc, String.trim(key), value)
_ -> acc
end
end)
case {Map.get(parts, "t"), Map.get(parts, "v1")} do
{t, v1} when is_binary(t) and is_binary(v1) -> {:ok, t, v1}
_ -> {:error, :invalid_signature}
_ -> {:error, :malformed}
end
rescue
_ -> {:error, :invalid_signature}
end
defp check_timestamp(timestamp) do
case Integer.parse(timestamp) do
# Step 4 (from docs): "calculate the difference between the current
# timestamp and the received timestamp, then decide if the difference
# is within your tolerance"
defp check_timestamp(timestamp_str) do
case Integer.parse(timestamp_str) do
{ts, ""} ->
now = System.system_time(:second)
if abs(now - ts) <= @max_age_seconds, do: :ok, else: {:error, :invalid_signature}
age = abs(System.system_time(:second) - ts)
if age <= @max_age_seconds, do: :ok, else: {:error, :expired}
_ ->
{:error, :invalid_signature}
{:error, :malformed}
end
end
defp get_event(%{"eventName" => event}) when is_binary(event), do: {:ok, event}
defp get_event(%{"event" => event}) when is_binary(event), do: {:ok, event}
defp get_event(_), do: {:error, :missing_event}
# ── Error handling ──
def action(conn, _opts) do
case apply(__MODULE__, action_name(conn), [conn, conn.params]) do
@ -138,8 +167,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do
{:error, :invalid_signature} ->
conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"})
{:error, :missing_event} ->
conn |> put_status(:bad_request) |> json(%{error: "Missing event field"})
%Plug.Conn{} = conn ->
conn
other ->
other