Packages

A Fair Source multi-agent runtime with deterministic agent scoring and replayable run history.

Current section

Files

Jump to
syntropy lib syntropy_web api_auth.ex
Raw

lib/syntropy_web/api_auth.ex

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