lib/openagents_web/plugs/dual_principal_auth.ex

main at 58e6347eeb72 · 2 KB

defmodule OpenAgentsWeb.Plugs.DualPrincipalAuth do
  @moduledoc "Authenticates either a human forge token or an agent credential."

  import Plug.Conn

  alias OpenAgents.Agents
  alias OpenAgents.ApiTokens

  def init(options), do: Keyword.fetch!(options, :human_scope)

  def call(conn, human_scope) do
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
         true <- token != "",
         {:ok, principal} <- authenticate(token, human_scope) do
      conn
      |> put_resp_header("cache-control", "no-store")
      |> assign_principal(principal)
    else
      _ -> refuse(conn)
    end
  end

  defp authenticate("oa_agent_" <> _rest = token, _human_scope) do
    case Agents.authenticate(token, "agent:participate") do
      {:ok, agent, credential} -> {:ok, {:agent, agent, credential}}
      _ -> {:error, :invalid_token}
    end
  end

  defp authenticate(token, human_scope) do
    case ApiTokens.authenticate(token, human_scope) do
      {:ok, user, credential} -> {:ok, {:user, user, credential}}
      _ -> {:error, :invalid_token}
    end
  end

  defp assign_principal(conn, {:agent, agent, credential}) do
    conn
    |> assign(:current_agent, agent)
    |> assign(:agent_token, credential)
    |> assign(:api_scope, "agent:participate")
  end

  defp assign_principal(conn, {:user, user, credential}) do
    conn
    |> assign(:current_user, user)
    |> assign(:api_token, credential)
    |> assign(:api_scope, "forge:write")
  end

  # A 401 from this pipeline is the first refusal an issue-family caller can
  # meet, so it carries the same envelope the controllers behind it use. The
  # `error` key predates the envelope and every measured client reads it, so it
  # rides beside the envelope rather than being replaced.
  defp refuse(conn) do
    body =
      OpenAgentsWeb.ApiError.envelope(conn, "unauthenticated",
        message: "Requires an API token with the scope this route needs",
        legacy: %{"error" => "invalid_api_token"}
      )

    conn
    |> put_status(:unauthorized)
    |> put_resp_header("cache-control", "no-store")
    |> Phoenix.Controller.json(body)
    |> halt()
  end
end