# `Daraja.Callback.Security`
[🔗](https://github.com/okothkongo/daraja/blob/v0.1.1/lib/daraja/callback/security.ex#L1)

Optional verification helpers for inbound M-PESA webhook requests.

Safaricom Daraja callbacks are unsigned JSON POSTs. Authenticity must be
enforced by the host application—typically via HTTPS, a shared secret embedded
in the callback URL, IP allowlisting, idempotency on transaction IDs, and
reconciliation against outbound API calls or Safaricom query/status APIs.

This module does **not** prove a payment occurred; it only helps reject
requests that clearly did not come through your expected transport controls.

## IP allowlist provenance

The built-in defaults are **community-documented** Safaricom Kenya callback
egress addresses widely cited in M-Pesa integrator guides and open-source
packages. They are **not** fetched from the live Daraja API and may change
without notice.

Defaults combine:

  * CIDR subnets `196.201.212.0/24`, `196.201.213.0/24`, and
    `196.201.214.0/24` (Safaricom `196.201.x.x` address space)
  * An explicit host list matching commonly published production callback IPs

Verify the current list with Safaricom support or your own production logs,
then override via application config:

    config :daraja,
      callback_cidrs: ["196.201.214.0/24", "196.201.213.0/24", "196.201.212.0/24"],
      callback_hosts: ["196.201.214.200", "196.201.212.127"]

Per-request overrides are also supported through `verify/1` and `safaricom_ip?/2`.

IP allowlisting is intended for **production**. Sandbox callbacks (for example
via ngrok) typically will not match these ranges—disable `check_ip` in dev.

## Example (Phoenix)

    with :ok <-
           Daraja.Callback.Security.verify(
             ip: conn.remote_ip,
             check_ip: true,
             shared_secret: Application.fetch_env!(:my_app, :mpesa_callback_secret),
             provided_secret: conn.params["token"]
           ),
         {:ok, callback} <- Daraja.Express.Callback.parse(payload),
         :ok <- Daraja.Callback.Guard.ensure_fresh(callback.checkout_request_id) do
      # ...fulfil order...
      json(conn, Daraja.Express.Callback.accept())
    else
      {:error, :untrusted_ip} -> send_resp(conn, 403, "Forbidden")
      {:error, :invalid_secret} -> send_resp(conn, 403, "Forbidden")
      {:error, :invalid_callback, _} -> send_resp(conn, 400, "Bad Request")
      {:error, :duplicate} -> json(conn, Daraja.Express.Callback.accept())
    end

Register callback URLs with a secret query parameter, for example
`https://example.com/mpesa/callback?token=your-secret`, and pass the same
value to `provided_secret`.

# `allowlist`

```elixir
@type allowlist() :: %{cidrs: [String.t()], hosts: [String.t()]}
```

# `verify_error`

```elixir
@type verify_error() :: :untrusted_ip | :invalid_secret | :invalid_ip
```

# `allowlist`

```elixir
@spec allowlist() :: allowlist()
```

Returns the active CIDR and host allowlists as a map.

# `callback_cidrs`

```elixir
@spec callback_cidrs() :: [String.t()]
```

Returns the configured callback CIDR allowlist.

Reads `:callback_cidrs` from `config :daraja, ...` when set, otherwise
`default_cidrs/0`.

# `callback_hosts`

```elixir
@spec callback_hosts() :: [String.t()]
```

Returns the configured explicit callback host allowlist.

Reads `:callback_hosts` from `config :daraja, ...` when set, otherwise
`known_callback_hosts/0`.

# `default_cidrs`

```elixir
@spec default_cidrs() :: [String.t()]
```

Built-in CIDR defaults shipped with the library.

# `known_callback_hosts`

```elixir
@spec known_callback_hosts() :: [String.t()]
```

Built-in explicit host defaults shipped with the library.

These mirror commonly published Safaricom production callback IPs cited in
integrator documentation. Prefer `callback_hosts/0` at runtime.

# `safaricom_ip?`

```elixir
@spec safaricom_ip?(term(), keyword() | [String.t()]) :: boolean()
```

Returns `true` when `ip` matches the active allowlist.

`ip` may be an IPv4 tuple, or a string parseable by `:inet.parse_address/1`.
Only IPv4 matching is supported.

When the second argument is a plain list of CIDR strings (no `cidr/host` keys),
only those CIDRs are checked—useful in tests. Otherwise pass `[]` or a keyword
list with optional `:cidrs` and `:hosts` overrides.

# `shared_secret_valid?`

```elixir
@spec shared_secret_valid?(term(), term()) :: boolean()
```

Constant-time comparison of callback URL secrets.

Returns `true` when both values are binaries and equal. Returns `false` when
either value is `nil`, not a binary, or they differ.

# `verify`

```elixir
@spec verify(keyword()) :: :ok | {:error, verify_error()}
```

Runs enabled verification checks. Skips any check whose inputs are omitted.

Options:

  * `:ip` — client address as an IPv4/IPv6 tuple or string (e.g. `conn.remote_ip`)
  * `:check_ip` — when `true`, requires `:ip` to match `:cidrs` and/or `:hosts`
  * `:cidrs` — CIDR strings for this request; defaults to `callback_cidrs/0`
  * `:hosts` — explicit IPv4 strings for this request; defaults to `callback_hosts/0`
  * `:shared_secret` — expected secret (e.g. from app config)
  * `:provided_secret` — secret from the callback URL query/path

Returns `:ok` or `{:error, reason}`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
