3.6 KiB · text History b868907
defmodule GitGudWeb.Api.Mobile.Auth do
  @moduledoc """
  Personal Access Token authentication for the mobile REST API.

  Used two ways:

    * as a **plug** in the `:mobile_api` pipeline — verifies the token,
      assigns `:api_user` and `:api_scopes`, and 401s otherwise, so no
      controller ever sees an unauthenticated request;
    * as a **helper** inside each action — `require_scope/2` narrows to
      the one scope that action needs.

  The split matters: the pipeline can't know whether a request is a read
  or a write, and a token scoped to `issues:read` must still be able to
  reach `GET /issues` while being turned away from `POST /comments`.

  The token arrives as `Authorization: token ggp_…` or
  `Authorization: Bearer ggp_…` — the app sends the latter, the docs
  show the former, and both are cheap to accept.

  A CI **job token** (`GitGud.Workflows.RunnerJwt`, a job's
  `GITHUB_TOKEN`) is accepted too, while its job runs. Its workflow's
  `issues` / `pull-requests` permissions become the `issues:*` /
  `pulls:*` scopes, and nothing else; the user is the forge's CI account
  (`Accounts.ci_user!/0`) and `:api_job` carries the token's claims —
  `Helpers.fetch_repo/5` limits it to the job's own repository, and
  `refuse_job/2` keeps it off what no pipeline should do.
  """
  import Plug.Conn

  alias GitGud.Accounts
  alias GitGud.Workflows.RunnerJwt

  @behaviour Plug

  @impl Plug
  def init(opts), do: opts

  @impl Plug
  def call(conn, _opts) do
    with token when is_binary(token) <- bearer(conn),
         {:ok, conn} <- authenticate(conn, token) do
      conn
    else
      _ -> deny(conn, 401, "invalid or missing API token")
    end
  end

  defp authenticate(conn, token) do
    case Accounts.verify_personal_access_token(token) do
      {:ok, user, scopes} ->
        {:ok, conn |> assign(:api_user, user) |> assign(:api_scopes, scopes)}

      :error ->
        case RunnerJwt.verify_active(token) do
          {:ok, %{"pid" => pid} = claims} when is_integer(pid) ->
            {:ok,
             conn
             |> assign(:api_user, Accounts.ci_user!())
             |> assign(:api_scopes, job_scopes(claims))
             |> assign(:api_job, claims)}

          _ ->
            :error
        end
    end
  end

  # What a job's permissions come to in this API's scopes.
  defp job_scopes(claims) do
    for {permission, scope} <- [{"issues", "issues"}, {"pull-requests", "pulls"}],
        level <- [:read, :write],
        RunnerJwt.permits?(claims, permission, level),
        do: "#{scope}:#{level}"
  end

  @doc """
  `:ok` for a person's token; a halted 403 for a job's, on what a
  pipeline may not do at any permission level (`what` says what).
  """
  def refuse_job(conn, what) do
    if conn.assigns[:api_job],
      do: {:error, deny(conn, 403, "a job token can't #{what}")},
      else: :ok
  end

  @doc """
  `{:ok, user}` when the presented token carries `scope`, otherwise a
  halted 403 conn. Written to be used in a `with`, so an action reads as
  a chain of the things that have to be true.
  """
  def require_scope(conn, scope) do
    if scope in (conn.assigns[:api_scopes] || []) do
      {:ok, conn.assigns.api_user}
    else
      {:error, deny(conn, 403, "token is missing the '#{scope}' scope")}
    end
  end

  defp bearer(conn) do
    case get_req_header(conn, "authorization") do
      ["token " <> t | _] -> t
      ["Bearer " <> t | _] -> t
      _ -> nil
    end
  end

  defp deny(conn, status, message) do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(%{"message" => message}))
    |> halt()
  end
end