defmodule SyntropyWeb.ApiAuth do @moduledoc """ Runtime auth policy for Syntropy's external REST and channel surface. Two token kinds are accepted: - the environment root token (`SYNTROPY_API_TOKEN`), which carries the `:admin` scope, and - database-backed scoped tokens (see `Syntropy.Persistence.ApiToken`), stored as SHA-256 digests with a `read`, `write`, or `admin` scope. Scopes are hierarchical: `admin > write > read`. """ import Plug.Conn, only: [get_req_header: 2] alias Syntropy.Persistence @type scope :: :read | :write | :admin @type validation_error :: :missing_token | :invalid_token | {:insufficient_scope, scope()} @type validation_result :: {:ok, scope()} | {:error, validation_error()} @scope_rank %{read: 0, write: 1, admin: 2} @spec required?() :: boolean() def required? do config() |> Keyword.get(:required, false) end @doc """ Validates the request bearer token and enforces the scope class required by the route (`:read`, `:write`, or `:admin`). """ @spec validate_conn(Plug.Conn.t(), scope()) :: validation_result() def validate_conn(conn, required_scope) do if required?() do conn |> get_req_header("authorization") |> List.first() |> extract_bearer() |> validate_token() |> check_scope(required_scope) else {:ok, :admin} end end @spec validate_socket(map()) :: validation_result() def validate_socket(params) do if required?() do params |> Map.get("token") |> validate_token() |> check_scope(:read) else {:ok, :admin} end end @spec scope_allows?(scope(), scope()) :: boolean() def scope_allows?(scope, required_scope) do Map.fetch!(@scope_rank, scope) >= Map.fetch!(@scope_rank, required_scope) end @spec error_message(validation_error()) :: String.t() def error_message(:missing_token), do: "Missing bearer token." def error_message(:invalid_token), do: "Invalid bearer token." def error_message({:insufficient_scope, required_scope}) do "This operation requires a token with the #{required_scope} scope." end @spec token() :: String.t() | nil def token do config() |> Keyword.get(:token) end @spec hash_token(String.t()) :: String.t() def hash_token(plaintext) when is_binary(plaintext) do :sha256 |> :crypto.hash(plaintext) |> Base.encode16(case: :lower) end defp config do Application.get_env(:syntropy, __MODULE__, []) end defp extract_bearer(header) when is_binary(header) do case String.split(header, ~r/\s+/, parts: 2, trim: true) do [scheme, token] when byte_size(token) > 0 -> if String.downcase(scheme) == "bearer", do: token, else: nil _other -> nil end end defp extract_bearer(_header), do: nil defp validate_token(nil), do: {:error, :missing_token} defp validate_token(provided_token) when is_binary(provided_token) do if root_token?(provided_token) do {:ok, :admin} else lookup_scoped_token(provided_token) end end defp root_token?(provided_token) do case token() do expected when is_binary(expected) and byte_size(expected) == byte_size(provided_token) -> Plug.Crypto.secure_compare(provided_token, expected) _other -> false end end defp lookup_scoped_token(provided_token) do case Persistence.find_active_api_token(hash_token(provided_token)) do %{scope: scope} when scope in ~w(read write admin) -> {:ok, String.to_existing_atom(scope)} _missing -> {:error, :invalid_token} end end defp check_scope({:error, reason}, _required_scope), do: {:error, reason} defp check_scope({:ok, scope}, required_scope) do if scope_allows?(scope, required_scope) do {:ok, scope} else {:error, {:insufficient_scope, required_scope}} end end end