Current section

Files

Jump to
stripity_stripe lib stripe webhook.ex
Raw

lib/stripe/webhook.ex

defmodule Stripe.Webhook do
@moduledoc """
Creates a Stripe Event from webhook's payload if signature is valid.
"""
@default_tolerance 300
@expected_scheme "v1"
@doc """
Verify webhook payload and return a Stripe event.
`payload` is the raw, unparsed content body sent by Stripe, which can be
retrieved with `Plug.Conn.read_body/2`. Note that `Plug.Parsers` will read
and discard the body, so you must implement a [custom body reader][1] if the
plug is located earlier in the pipeline.
`signature` is the value of `Stripe-Signature` header, which can be fetched
with `Plug.Conn.get_req_header/2`.
`secret` is your webhook endpoint's secret from the Stripe Dashboard.
`tolerance` is the allowed deviation in seconds from the current system time
to the timestamp found in `signature`. Defaults to 300 seconds (5 minutes).
`opts` is a keyword list of options. Supported options:
* `:response_as` - controls the shape of the value returned in the `:ok`
tuple. One of:
* `:struct` (default) - returns a `Stripe.Event.t()`.
* `:map` - returns the decoded payload as a map with string keys (useful
when persisting webhooks for later replay or struct conversion via
`Stripe.Converter.convert_result/1`).
* `:raw` - returns the original `payload` string verbatim.
When `tolerance` is omitted, `opts` may be passed directly as the 4th
argument:
Stripe.Webhook.construct_event(payload, signature, secret, response_as: :map)
Stripe API reference:
https://stripe.com/docs/webhooks/signatures#verify-manually
[1]: https://hexdocs.pm/plug/Plug.Parsers.html#module-custom-body-reader
## Example
case Stripe.Webhook.construct_event(payload, signature, secret) do
{:ok, %Stripe.Event{} = event} ->
# Return 200 to Stripe and handle event
{:error, reason} ->
# Reject webhook by responding with non-2XX
end
"""
@spec construct_event(
String.t(),
String.t(),
String.t(),
integer | Keyword.t(),
Keyword.t()
) :: {:ok, Stripe.Event.t() | map() | String.t()} | {:error, any}
def construct_event(payload, signature_header, secret, tolerance_or_opts \\ @default_tolerance, opts \\ [])
def construct_event(payload, signature_header, secret, opts, []) when is_list(opts) do
construct_event(payload, signature_header, secret, @default_tolerance, opts)
end
def construct_event(payload, signature_header, secret, tolerance, opts) when is_integer(tolerance) do
case verify_header(payload, signature_header, secret, tolerance) do
:ok -> {:ok, format_response(payload, Keyword.get(opts, :response_as, :struct))}
error -> error
end
end
defp format_response(payload, :struct), do: convert_to_event!(payload)
defp format_response(payload, :map), do: convert_to_map!(payload)
defp format_response(payload, :raw), do: payload
defp verify_header(payload, signature_header, secret, tolerance) do
case get_timestamp_and_signatures(signature_header, @expected_scheme) do
{nil, _} ->
{:error, "Unable to extract timestamp and signatures from header"}
{_, []} ->
{:error, "No signatures found with expected scheme #{@expected_scheme}"}
{timestamp, signatures} ->
with {:ok, timestamp} <- check_timestamp(timestamp, tolerance),
{:ok, _signatures} <- check_signatures(signatures, timestamp, payload, secret) do
:ok
else
{:error, error} -> {:error, error}
end
end
end
defp get_timestamp_and_signatures(signature_header, scheme) do
signature_header
|> String.split(",")
|> Enum.map(&String.split(&1, "="))
|> Enum.reduce({nil, []}, fn
["t", timestamp], {nil, signatures} ->
{to_integer(timestamp), signatures}
[^scheme, signature], {timestamp, signatures} ->
{timestamp, [signature | signatures]}
_, acc ->
acc
end)
end
defp to_integer(timestamp) do
case Integer.parse(timestamp) do
{timestamp, _} ->
timestamp
:error ->
nil
end
end
defp check_timestamp(timestamp, tolerance) do
now = System.system_time(:second)
tolerance_zone = now - tolerance
if timestamp < tolerance_zone do
{:error, "Timestamp outside the tolerance zone (#{now})"}
else
{:ok, timestamp}
end
end
defp check_signatures(signatures, timestamp, payload, secret) do
signed_payload = "#{timestamp}.#{payload}"
expected_signature = compute_signature(signed_payload, secret)
if Enum.any?(signatures, &secure_equals?(&1, expected_signature)) do
{:ok, signatures}
else
{:error, "No signatures found matching the expected signature for payload"}
end
end
defp compute_signature(payload, secret) do
digest = hmac(:sha256, secret, payload)
Base.encode16(digest, case: :lower)
end
# Remove when OTP 22 compatibility is no longer needed
if System.otp_release() >= "22" do
defp hmac(digest, key, data), do: :crypto.mac(:hmac, digest, key, data)
else
defp hmac(digest, key, data), do: :crypto.hmac(digest, key, data)
end
defp secure_equals?(input, expected) when byte_size(input) == byte_size(expected) do
input = String.to_charlist(input)
expected = String.to_charlist(expected)
secure_compare(input, expected)
end
defp secure_equals?(_, _), do: false
defp secure_compare(acc \\ 0, input, expected)
defp secure_compare(acc, [], []), do: acc == 0
defp secure_compare(acc, [input_codepoint | input], [expected_codepoint | expected]) do
import Bitwise
acc
|> bor(bxor(input_codepoint, expected_codepoint))
|> secure_compare(input, expected)
end
defp convert_to_map!(payload) do
Stripe.API.json_library().decode!(payload)
end
defp convert_to_event!(payload) do
payload |> convert_to_map!() |> Stripe.Converter.convert_result()
end
end