defmodule ToweropsWeb.Api.V1.StripeWebhookController do use ToweropsWeb, :controller alias Towerops.Workers.StripeWebhookWorker require Logger def create(conn, _params) do signature = conn |> get_req_header("stripe-signature") |> List.first() raw_body = conn.private[:raw_body] || "" with :ok <- verify_signature(raw_body, signature), {:ok, event} <- decode_payload(raw_body) do case Oban.insert(StripeWebhookWorker.new(%{event: event})) do {:ok, _job} -> json(conn, %{received: true}) {:error, changeset} -> Logger.error("Failed to enqueue Stripe webhook: #{inspect(changeset.errors)}") conn |> put_status(500) |> json(%{error: "Failed to enqueue"}) end else {:error, :invalid_signature} -> conn |> put_status(401) |> json(%{error: "Invalid signature"}) {:error, :invalid_json} -> conn |> put_status(400) |> json(%{error: "Invalid JSON payload"}) {:error, reason} -> Logger.error("Stripe webhook verification failed: #{inspect(reason)}") conn |> put_status(400) |> json(%{error: "Verification failed"}) end end defp decode_payload(raw_body) do case Jason.decode(raw_body) do {:ok, event} -> {:ok, event} {:error, _} -> {:error, :invalid_json} end end defp verify_signature(payload, signature_header) do webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret) with {:ok, timestamp, signature} <- parse_signature_header(signature_header), :ok <- check_timestamp_tolerance(timestamp, 300) do signed_payload = "#{timestamp}.#{payload}" expected_signature = :hmac |> :crypto.mac(:sha256, webhook_secret, signed_payload) |> Base.encode16(case: :lower) if Plug.Crypto.secure_compare(signature, expected_signature) do :ok else {:error, :invalid_signature} end end end defp parse_signature_header(header) do parts = String.split(header, ",") timestamp = Enum.find_value(parts, fn part -> case String.split(part, "=") do ["t", t] -> t _ -> nil end end) signature = Enum.find_value(parts, fn part -> case String.split(part, "=") do ["v1", sig] -> sig _ -> nil end end) if timestamp && signature do {:ok, timestamp, signature} else {:error, :malformed_header} end end defp check_timestamp_tolerance(timestamp_str, max_age_seconds) do timestamp = String.to_integer(timestamp_str) now = System.system_time(:second) if abs(now - timestamp) <= max_age_seconds do :ok else {:error, :timestamp_too_old} end end end