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 user API tokens (`ggp_…`) and
finally an email + password pair.
"""
alias GitGud.Accounts
alias GitGud.Accounts.User
alias GitGud.Packages
alias GitGud.Repositories.Repository
@typedoc """
Who is making the request.
* `{:user, %User{}}` — a forge account, via password or `ggp_…`
* `{:token, repository_id, scopes}` — a repo-scoped `ggrt_…`
* `:anonymous`
"""
@type principal :: {:user, User.t()} | {:token, integer(), [String.t()]} | :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 ->
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)
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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.9 KiB · text
History
d70ff9f