6.4 KiB · text History 95e9713
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 elsewhere read only what another repository
  has granted it (`GitGud.Workflows.JobTokenGrants`). 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.

  ## Who may do what

  Decided by `GitGud.Packages.Access`, which the container registry's
  token endpoint asks too.
  """

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

  @typedoc """
  Who is making the request.

    * `{:user, %User{}, scopes}` — a forge account: `:all` via password,
      the token's scopes via `ggp_…`
    * `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
    * `{:job, repository_id}` — a running CI job's `${{ github.token }}`
    * `:anonymous`
  """
  @type principal :: Access.principal()

  @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
          # The token's scopes travel with it: a `ci:read` token is not a
          # way to publish packages.
          {:ok, %User{} = user, scopes} ->
            {:user, user, scopes}

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

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

  Same question as `authorize/3`, plus the one exception to "packages
  follow the repository": a repository may name formats it publishes
  regardless of its visibility, and for those a read is allowed to
  anyone.

  It widens reads only, and only for the named format. A repository
  publishing `fdroid` has not opened its `raw` bucket, has not opened
  publishing to anonymous callers, and has not made its source
  readable — nothing here is consulted outside the package registry.
  """
  @spec authorize(principal(), Repository.t(), :read | :write, String.t() | nil) :: boolean()
  def authorize(principal, %Repository{} = repo, :read, format) when is_binary(format) do
    authorize(principal, repo, :read) or public_format?(repo, format)
  end

  def authorize(principal, %Repository{} = repo, action, _format),
    do: authorize(principal, repo, action)

  @doc """
  Does `repo` publish `format` regardless of its visibility?
  """
  @spec public_format?(Repository.t(), String.t()) :: boolean()
  def public_format?(%Repository{public_package_formats: formats}, format)
      when is_list(formats),
      do: format in formats

  def public_format?(%Repository{}, _format), do: false

  @doc """
  May `principal` perform `action` (`:read` or `:write`) on `repo`?
  See `GitGud.Packages.Access`.
  """
  @spec authorize(principal(), Repository.t(), :read | :write) :: boolean()
  def authorize(principal, %Repository{} = repo, action) when action in [:read, :write],
    do: Access.allowed?(principal, repo, action)

  @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