6.2 KiB · text History aab4e3f
defmodule GitGud.Packages.Auth do
  @moduledoc """
  Authentication for the non-OCI package registries.

  `RegistryToken` is already format-agnostic — a `ggrt_…` string
  scoped to one repository with `pull`/`push`. What differs per
  ecosystem is only how the publish tool *presents* it:

      twine   Authorization: Basic base64("__token__:ggrt_…")
      pip     Authorization: Basic base64("<user>:<secret>")  (or in the URL)
      mix     Authorization: ggrt_…            (bare, no scheme)
      cargo   Authorization: ggrt_…            (bare, no scheme)
      apt     Authorization: Basic …           (from /etc/apt/auth.conf)

  So credential extraction tries, in order: HTTP Basic (either half may
  carry the secret, since `__token__` occupies the username), a bare
  `Authorization` value, and `Bearer`. Each candidate is then checked
  against the registry tokens, the per-job CI tokens, the user API
  tokens (`ggp_…`) and finally an email + password pair.

  ## CI publishes without stored credentials

  A workflow's `${{ github.token }}` is a `GitGud.Workflows.RunnerJwt`
  minted for one job, and it authenticates here the same way it already
  does at the OCI token endpoint: it may read and write its *own*
  repository's packages, and nothing anywhere else. That is what lets a
  publish step be pasted into a workflow with no secret to create
  first — and it is the same deal the container registry already
  offers, so the two don't need different instructions.
  """

  alias GitGud.Accounts
  alias GitGud.Accounts.User
  alias GitGud.Packages
  alias GitGud.Repositories.Repository
  alias GitGud.Workflows.RunnerJwt

  @typedoc """
  Who is making the request.

    * `{:user, %User{}}` — a forge account, via password or `ggp_…`
    * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
    * `{:job, repository_id}` — a running CI job's `${{ github.token }}`
    * `:anonymous`
  """
  @type principal ::
          {:user, User.t()}
          | {:token, integer(), [String.t()]}
          | {:job, integer()}
          | :anonymous

  @doc """
  Identify the caller from the request's `Authorization` header.
  Always succeeds — an unauthenticated or unrecognised request is
  `:anonymous`, and authorization is a separate decision.
  """
  @spec authenticate(Plug.Conn.t()) :: principal()
  def authenticate(conn) do
    conn
    |> credentials()
    |> Enum.find_value(:anonymous, &verify/1)
  end

  # Every credential shape the request might be carrying, most specific
  # first. `{username, secret}`; `username` is nil where the scheme
  # doesn't carry one.
  defp credentials(conn) do
    case Plug.Conn.get_req_header(conn, "authorization") do
      ["Basic " <> b64 | _] ->
        case decode_basic(b64) do
          {:ok, username, secret} ->
            # twine puts the token in the password with `__token__` as
            # the username; some tools do the reverse.
            [{username, secret}, {nil, secret}, {nil, username}]

          :error ->
            []
        end

      ["Bearer " <> secret | _] ->
        [{nil, secret}]

      [raw | _] ->
        [{nil, String.trim(raw)}]

      [] ->
        []
    end
  end

  defp decode_basic(b64) do
    with {:ok, decoded} <- Base.decode64(b64),
         [username, secret] <- String.split(decoded, ":", parts: 2) do
      {:ok, username, secret}
    else
      _ -> :error
    end
  end

  defp verify({username, secret}) do
    case Packages.verify_registry_token(secret) do
      {:ok, repo_id, scopes} ->
        {:token, repo_id, scopes}

      :error ->
        verify_job_or_user(username, secret)
    end
  end

  defp verify_job_or_user(username, secret) do
    case RunnerJwt.verify(secret) do
      {:ok, %{"pid" => repo_id}} when is_integer(repo_id) ->
        {:job, repo_id}

      _ ->
        case Accounts.verify_personal_access_token(secret) do
          {:ok, %User{} = user, _scopes} ->
            {:user, user}

          :error ->
            with true <- is_binary(username),
                 %User{} = user <- Accounts.get_user_by_email_and_password(username, secret) do
              {:user, user}
            else
              _ -> nil
            end
        end
    end
  end

  @doc """
  May `principal` perform `action` (`:read` or `:write`) on `repo`?

  Reads follow repository visibility; writes are the owner's, or a
  `ggrt_…` minted for that repository with the matching scope.
  """
  @spec authorize(principal(), Repository.t(), :read | :write) :: boolean()
  def authorize({:token, repo_id, scopes}, %Repository{id: repo_id}, action) do
    scope_for(action) in scopes
  end

  # A token minted for a different repository is worth no more than
  # an anonymous request.
  def authorize({:token, _other, _}, %Repository{} = repo, action),
    do: authorize(:anonymous, repo, action)

  # A job token carries the forge's own judgement that this job belongs
  # to this repository, so it reads and writes that repository's
  # packages with no stored credential — and, exactly as at the OCI
  # token endpoint, gets nothing at all anywhere else.
  def authorize({:job, repo_id}, %Repository{id: repo_id}, _action), do: true

  def authorize({:job, _other}, %Repository{} = repo, action),
    do: authorize(:anonymous, repo, action)

  def authorize({:user, %User{} = user}, %Repository{} = repo, action) do
    repo = GitGud.Repo.preload(repo, [:owner, :organization])

    cond do
      user.id == repo.owner_id -> true
      action == :read -> repo.visibility in ["public", "internal"]
      true -> org_writer?(repo, user)
    end
  end

  def authorize(:anonymous, %Repository{visibility: vis}, :read), do: vis == "public"
  def authorize(:anonymous, %Repository{}, :write), do: false

  defp scope_for(:read), do: "pull"
  defp scope_for(:write), do: "push"

  defp org_writer?(%Repository{organization: %_{} = org}, %User{} = user),
    do: GitGud.Organizations.member?(org, user)

  defp org_writer?(_repo, _user), do: false

  @doc """
  The `WWW-Authenticate` challenge to send with a 401.

  `mix` and `cargo` never follow a challenge — they send their token
  or fail — but `pip` and `apt` both retry against Basic, so this is
  what makes a private index usable from a bare URL.
  """
  def challenge(format) do
    ~s(Basic realm="gitgud #{format} registry", charset="UTF-8")
  end
end