defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do @moduledoc """ Webhook endpoint for receiving real-time updates from Gaiia. Implements signature verification per Gaiia's webhook security docs: https://app.gaiia.com/docs/webhooks/security ## Gaiia's verification algorithm: 1. **Extract** timestamp (`t`) and signature (`v1`) from the `X-Gaiia-Webhook-Signature` header. Format: `t=,v1=` 2. **Prepare** the signed payload: `.` 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 """ use ToweropsWeb, :controller alias Towerops.Gaiia.Webhooks alias Towerops.Integrations require Logger @max_age_seconds 300 # ── Main action ── def create(conn, %{"organization_id" => organization_id}) do with {:ok, integration} <- get_gaiia_integration(organization_id), :ok <- verify_webhook(conn, integration) do process_webhook(conn, organization_id) else {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"}) {:error, :no_secret_configured} -> conn |> put_status(:forbidden) |> json(%{error: "Webhook secret not configured"}) {:error, :invalid_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"}) {:error, reason} -> conn |> put_status(:unauthorized) |> json(%{error: "Webhook verification failed: #{reason}"}) 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")) 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} {:error, :not_found} -> {:error, :not_found} end end # ── Signature verification per Gaiia docs ── defp verify_webhook(conn, integration) do secret = get_in(integration.credentials, ["webhook_secret"]) sig_headers = Plug.Conn.get_req_header(conn, "x-gaiia-webhook-signature") cond do is_nil(secret) or secret == "" -> Logger.warning("Gaiia webhook: rejected - no webhook secret configured") {:error, :no_secret_configured} sig_headers == [] -> Logger.warning("Gaiia webhook: secret configured but no signature header received") {:error, :missing_signature} true -> raw_body = conn.private[:raw_body] || "" verify_gaiia_signature(hd(sig_headers), raw_body, secret) end end # 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 = "." signed_payload = timestamp <> "." <> raw_body # Step 3: HMAC-SHA256, hex-encoded lowercase expected = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) # Step 4: Constant-time comparison if Plug.Crypto.secure_compare(expected, v1_signature) do :ok else Logger.warning( "Gaiia webhook signature mismatch — " <> "ts=#{timestamp} body_len=#{byte_size(raw_body)}" ) {: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.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, :malformed} end end # 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, ""} -> # Gaiia sends timestamps in milliseconds — normalize to seconds ts_seconds = if ts > 9_999_999_999, do: div(ts, 1000), else: ts age = abs(System.system_time(:second) - ts_seconds) if age <= @max_age_seconds, do: :ok, else: {:error, :expired} _ -> {:error, :malformed} end end # ── Error handling ── def action(conn, _opts) do case apply(__MODULE__, action_name(conn), [conn, conn.params]) do {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Gaiia integration not found"}) {:error, :invalid_signature} -> conn |> put_status(:unauthorized) |> json(%{error: "Invalid webhook signature"}) %Plug.Conn{} = conn -> conn other -> other end end end