Skip to main content

lib/meili/token.ex

defmodule Meili.Token do
  @moduledoc """
  Generates signed tenant tokens for multi-tenant search filtering.
  """

  @doc """
  Generates a signed Meilisearch Tenant Token using the HS256 signing algorithm.

  ## Parameters
  - `api_key_uid`: The UUID v4 of the API key.
  - `search_rules`: The search rules map or list of rules.
  - `secret`: The API key value itself used for signing (or defaults to the default client's key).
  - `opts`: Options:
    - `:expires_at`: The expiration time as a `DateTime` struct or integer UNIX timestamp.

  ## Examples
      rules = %{"movies" => %{"filter" => "genre = 'Sci-Fi'"}}
      {:ok, token} = Meili.Token.generate("e5b72186-dc3c-4cf2-835b-d0df3f08985c", rules, "my-master-key")
  """
  @spec generate(String.t() | nil, map() | nil, String.t() | nil, Keyword.t()) ::
          {:ok, String.t()} | {:error, :missing_secret | :invalid_uid | :missing_search_rules}
  def generate(api_key_uid, search_rules, secret \\ nil, opts \\ []) do
    secret = secret || default_secret()

    cond do
      is_nil(secret) or secret == "" ->
        {:error, :missing_secret}

      is_nil(api_key_uid) or not valid_uuid?(api_key_uid) ->
        {:error, :invalid_uid}

      is_nil(search_rules) ->
        {:error, :missing_search_rules}

      true ->
        claims =
          %{
            "apiKeyUid" => api_key_uid,
            "searchRules" => search_rules
          }
          |> put_expiration(opts)

        {:ok, generate_jwt(claims, secret)}
    end
  end

  defp put_expiration(claims, opts) do
    case opts[:expires_at] do
      nil ->
        claims

      %DateTime{} = dt ->
        Map.put(claims, "exp", DateTime.to_unix(dt))

      ts when is_integer(ts) ->
        Map.put(claims, "exp", ts)
    end
  end

  @doc """
  Generates a signed Meilisearch Tenant Token, raising on error.

  ## Examples
      rules = %{"movies" => %{"filter" => "genre = 'Sci-Fi'"}}
      token = Meili.Token.generate!("e5b72186-dc3c-4cf2-835b-d0df3f08985c", rules, "my-master-key")
  """
  @spec generate!(String.t() | nil, map() | nil, String.t() | nil, Keyword.t()) ::
          String.t() | no_return()
  def generate!(api_key_uid, search_rules, secret \\ nil, opts \\ []) do
    case generate(api_key_uid, search_rules, secret, opts) do
      {:ok, token} -> token
      {:error, reason} -> raise "Failed to generate tenant token: #{inspect(reason)}"
    end
  end

  defp default_secret do
    Meili.default_client().key
  rescue
    _ -> nil
  end

  defp valid_uuid?(uuid) do
    uuid_regex = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
    String.match?(uuid, uuid_regex)
  end

  defp generate_jwt(payload, secret) do
    header = %{"alg" => "HS256", "typ" => "JWT"}

    header_b64 = encode_b64url(JSON.encode!(header))
    payload_b64 = encode_b64url(JSON.encode!(payload))

    signing_input = header_b64 <> "." <> payload_b64

    signature = :crypto.mac(:hmac, :sha256, secret, signing_input)
    signature_b64 = encode_b64url(signature)

    signing_input <> "." <> signature_b64
  end

  defp encode_b64url(data) do
    Base.encode64(data)
    |> String.replace("+", "-")
    |> String.replace("/", "_")
    |> String.replace("=", "")
  end
end