defmodule GitGud.Packages.Token do
@moduledoc """
Issues OCI distribution Bearer tokens for the container registry.
Configured zot points its `auth.tokenAuth.realm` at our
`/v2/token` endpoint. On 401 from zot, the docker client follows the
`WWW-Authenticate` header here, authenticates over Basic auth, and we
hand back a JWT scoped to the resource it requested.
Tokens are signed with an RS256 key shared with zot
(zot reads the public key from its config). Configure via:
config :git_gud, GitGud.Packages.Token,
issuer: "gitgud-registry",
audience: "container_registry",
private_key_pem: System.get_env("GITGUD_REGISTRY_PRIVATE_KEY"),
key_id: "gitgud-registry-1",
ttl_seconds: 300
"""
alias GitGud.Repositories.Repository
@type access :: %{type: String.t(), name: String.t(), actions: [String.t()]}
@doc """
Build the JWT payload then sign it. `scopes` is a list of strings as
sent in the `scope=` query parameter, e.g.
`repository:owner/name:pull,push`.
"""
def issue(subject, scopes) when is_list(scopes) do
access = Enum.map(scopes, &parse_scope/1) |> Enum.reject(&is_nil/1)
config = Application.get_env(:git_gud, __MODULE__, [])
issuer = Keyword.get(config, :issuer, "gitgud-registry")
audience = Keyword.get(config, :audience, "container_registry")
ttl = Keyword.get(config, :ttl_seconds, 300)
now = System.system_time(:second)
claims = %{
"iss" => issuer,
"sub" => subject || "anonymous",
"aud" => audience,
"exp" => now + ttl,
"nbf" => now - 10,
"iat" => now,
"jti" => :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower),
"access" => Enum.map(access, &render_access/1)
}
sign(claims, config)
end
defp parse_scope("repository:" <> rest) do
case String.split(rest, ":", parts: 2) do
[name, actions] ->
%{type: "repository", name: name, actions: String.split(actions, ",")}
_ ->
nil
end
end
defp parse_scope(_), do: nil
defp render_access(%{type: t, name: n, actions: a}),
do: %{"type" => t, "name" => n, "actions" => a}
defp sign(claims, config) do
pem = Keyword.get(config, :private_key_pem) || raise "GITGUD_REGISTRY_PRIVATE_KEY not set"
kid = Keyword.get(config, :key_id, "gitgud-registry-1")
signer = Joken.Signer.create("RS256", %{"pem" => pem}, %{"kid" => kid})
Joken.encode_and_sign(claims, signer)
end
@doc """
Decide whether `user` (or anonymous) may perform `actions` on a
registry-style repository name (`owner/name`).
"""
def authorize(user, repo_path, actions) when is_binary(repo_path) and is_list(actions) do
case String.split(repo_path, "/", parts: 2) do
[owner_handle, name] ->
try do
repo = GitGud.Repositories.get_repository_by_path!(owner_handle, name)
authorize_repo(user, repo, actions)
rescue
Ecto.NoResultsError -> []
end
_ ->
[]
end
end
@doc """
Generate a fresh RSA keypair for token signing. Returns a tuple of
`{private_pem, public_pem}` where:
* `private_pem` is PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`) —
what Joken/JOSE accept directly via `Joken.Signer.create/3`.
* `public_pem` is SubjectPublicKeyInfo (`-----BEGIN PUBLIC KEY-----`) —
what zot's `auth.bearer.cert` parses with ParsePKIXPublicKey.
Used by `Mix.Tasks.Gitgud.Registry.Keygen` (dev/local) and
`GitGud.Release.keygen/1` (in-pod, no Mix).
"""
def generate_keypair(bits \\ 4096) when is_integer(bits) and bits >= 2048 do
rsa_private = :public_key.generate_key({:rsa, bits, 65_537})
priv_pem = encode_pem(:RSAPrivateKey, rsa_private)
rsa_public = {:RSAPublicKey, elem(rsa_private, 2), elem(rsa_private, 3)}
pub_pem = encode_pem(:SubjectPublicKeyInfo, rsa_public)
{priv_pem, pub_pem}
end
defp encode_pem(asn1_type, entity) do
entry = :public_key.pem_entry_encode(asn1_type, entity)
:public_key.pem_encode([entry])
end
defp authorize_repo(user, %Repository{} = repo, actions) do
repo = GitGud.Repo.preload(repo, :owner)
is_owner = user && user.id == repo.owner_id
Enum.filter(actions, fn
"pull" -> repo.visibility in ["public", "internal"] or is_owner
"push" -> is_owner
"delete" -> is_owner
_ -> false
end)
end
end
4.3 KiB · text
5af55d9