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