4.9 KiB · text 5af55d9
defmodule GitGudWeb.RegistryController do
@moduledoc """
OCI distribution token endpoint (the `realm` zot points at).
Flow:
1. `docker pull host/owner/repo:tag` hits zot, no token → 401 with
`WWW-Authenticate: Bearer realm="https://gitgud/v2/token",
service="container_registry", scope="repository:owner/repo:pull"`
2. Client re-requests with Basic auth + the scope; we authenticate
the user, filter the requested actions, and return a signed JWT.
3. Client retries against zot with `Authorization: Bearer <jwt>`;
zot validates the signature with our shared public key and lets
the operation through.
"""
use GitGudWeb, :controller
alias GitGud.Accounts
alias GitGud.Packages
alias GitGud.Packages.Token
alias GitGud.Repositories
alias GitGud.Repositories.Storage
alias GitGud.Workflows.RunnerJwt
def token(conn, params) do
{sub, access} = authorize(authenticate(conn), List.wrap(params["scope"]))
case Token.issue(sub, access) do
{:ok, jwt, claims} ->
json(conn, %{
"token" => jwt,
"access_token" => jwt,
"expires_in" => claims["exp"] - System.system_time(:second),
"issued_at" => DateTime.from_unix!(claims["iat"]) |> DateTime.to_iso8601()
})
{:error, reason} ->
conn
|> put_status(500)
|> json(%{"errors" => [%{"code" => "TOKEN_ERROR", "message" => inspect(reason)}]})
end
end
# Authenticated principal for the token request:
# {:user, %User{}} — forge email + password (humans / manual creds)
# {:job, "owner/name"} — a CI job's runner token (forge-managed auth):
# the workflow logs in with `password: ${{ github.token }}`, so the
# Basic-auth password is the per-job RunnerJwt; it may push/pull
# only its own repository.
# :anonymous
defp authenticate(conn) do
case basic_creds(conn) do
{:ok, username, secret} ->
case RunnerJwt.verify(secret) do
{:ok, %{"pid" => pid}} ->
case job_repo_path(pid) do
nil -> :anonymous
path -> {:job, path}
end
_ ->
case Packages.verify_registry_token(secret) do
{:ok, repo_id, scopes} ->
case job_repo_path(repo_id) do
nil -> :anonymous
path -> {:deploy, path, scopes}
end
:error ->
case Accounts.get_user_by_email_and_password(username, secret) do
%_{} = user -> {:user, user}
_ -> :anonymous
end
end
end
:error ->
:anonymous
end
end
defp basic_creds(conn) do
with ["Basic " <> b64] <- Plug.Conn.get_req_header(conn, "authorization"),
{:ok, decoded} <- Base.decode64(b64),
[username, secret] <- String.split(decoded, ":", parts: 2) do
{:ok, username, secret}
else
_ -> :error
end
end
defp authorize({:user, user}, scopes) do
{user.email, Enum.map(scopes, &user_scope(user, &1))}
end
defp authorize(:anonymous, scopes) do
{nil, Enum.map(scopes, &user_scope(nil, &1))}
end
# A repo-scoped deploy token grants its own `pull`/`push` scopes on its
# own repo; other repos fall back to anonymous (public pull only).
defp authorize({:deploy, repo_path, token_scopes}, scopes) do
access =
Enum.map(scopes, fn scope ->
case String.split(scope, ":", parts: 3) do
["repository", ^repo_path, actions] ->
granted = Enum.filter(String.split(actions, ","), &(&1 in token_scopes))
"repository:" <> repo_path <> ":" <> Enum.join(granted, ",")
_ ->
user_scope(nil, scope)
end
end)
{"deploy-token:" <> repo_path, access}
end
# A job token grants push+pull on its own repo; other repos fall back to
# anonymous (public pull only).
defp authorize({:job, job_path}, scopes) do
access =
Enum.map(scopes, fn scope ->
case String.split(scope, ":", parts: 3) do
["repository", ^job_path, actions] ->
granted = Enum.filter(String.split(actions, ","), &(&1 in ["pull", "push"]))
"repository:" <> job_path <> ":" <> Enum.join(granted, ",")
_ ->
user_scope(nil, scope)
end
end)
{"runner-task:" <> job_path, access}
end
defp user_scope(user, scope) do
case String.split(scope, ":", parts: 3) do
["repository", path, actions] ->
granted = Token.authorize(user, path, String.split(actions, ","))
"repository:" <> path <> ":" <> Enum.join(granted, ",")
_ ->
scope
end
end
defp job_repo_path(pid) when is_integer(pid) do
repo = Repositories.get_repository!(pid) |> GitGud.Repo.preload([:owner, :organization])
Storage.repo_handle(repo) <> "/" <> repo.name
rescue
_ -> nil
end
defp job_repo_path(_), do: nil
end