Current section

Files

Jump to
testcontainers lib docker auth.ex
Raw

lib/docker/auth.ex

# SPDX-License-Identifier: MIT
defmodule Testcontainers.Docker.Auth do
@moduledoc """
Resolves Docker registry credentials from the user's Docker config file
(typically `~/.docker/config.json`) and returns a ready-to-send
`X-Registry-Auth` header value.
Scope:
* Only the `auths` map in `config.json` is supported.
* Credential helpers (`credsStore`, `credHelpers`) are intentionally out
of scope — if encountered, a debug log is emitted and `nil` is returned
so the caller can fall back to anonymous access.
The `DOCKER_CONFIG` environment variable is honoured: when set, the config
file is read from `$DOCKER_CONFIG/config.json`; otherwise the default path
`~/.docker/config.json` is used.
The header value is a URL-safe base64 encoding (without padding) of a JSON
document describing the credentials, as specified by the Docker Engine API.
"""
require Logger
@docker_hub_key "https://index.docker.io/v1/"
@doc """
Resolves registry credentials for the given `image` and returns the
ready-to-send `X-Registry-Auth` header value, or `nil` if no matching
credentials can be found.
`config_path` may be `nil`, in which case the default lookup logic is used
(respecting the `DOCKER_CONFIG` environment variable).
"""
@spec resolve(String.t(), String.t() | nil) :: String.t() | nil
def resolve(image, config_path \\ nil) when is_binary(image) do
case read_config(config_path) do
{:ok, config} ->
registry = registry_for_image(image)
resolve_from_config(config, registry)
:error ->
nil
end
end
@doc """
Returns the registry key that should be used for looking up credentials
for the given `image` (Docker config convention).
Unnamespaced or explicitly `docker.io`-hosted images resolve to
`https://index.docker.io/v1/`; everything else resolves to the registry
host component of the image reference.
"""
@spec registry_for_image(String.t()) :: String.t()
def registry_for_image(image) when is_binary(image) do
case String.split(image, "/", parts: 2) do
[_single] -> @docker_hub_key
[maybe_host, _rest] -> registry_from_host_component(maybe_host)
end
end
defp registry_from_host_component(component) do
cond do
not host?(component) -> @docker_hub_key
component in ["docker.io", "index.docker.io"] -> @docker_hub_key
true -> component
end
end
# A registry host contains a "." or ":" or is exactly "localhost".
defp host?(component) do
component == "localhost" or String.contains?(component, ".") or
String.contains?(component, ":")
end
defp read_config(nil), do: read_config(default_config_path())
defp read_config(path) when is_binary(path) do
with {:ok, contents} <- File.read(path),
{:ok, decoded} <- Jason.decode(contents) do
{:ok, decoded}
else
{:error, reason} ->
Logger.debug(
"Testcontainers.Docker.Auth: could not read Docker config at #{path}: #{inspect(reason)}"
)
:error
end
end
defp default_config_path do
case System.get_env("DOCKER_CONFIG") do
nil -> Path.join(System.user_home() || "", ".docker/config.json")
"" -> Path.join(System.user_home() || "", ".docker/config.json")
dir -> Path.join(dir, "config.json")
end
end
defp resolve_from_config(config, registry) do
auths = Map.get(config, "auths", %{})
case find_auth_entry(auths, registry) do
{matched_key, %{"auth" => encoded}} when is_binary(encoded) and encoded != "" ->
build_header(encoded, matched_key)
{_matched_key, _entry} ->
maybe_warn_cred_helper(config, registry)
nil
:not_found ->
maybe_warn_cred_helper(config, registry)
nil
end
end
defp find_auth_entry(auths, registry) when is_map(auths) do
candidates = candidate_keys(registry)
Enum.find_value(candidates, :not_found, fn key ->
case Map.get(auths, key) do
nil -> nil
entry when is_map(entry) -> {key, entry}
_ -> nil
end
end)
end
defp find_auth_entry(_auths, _registry), do: :not_found
# Docker config.json keys are sometimes stored with scheme/path prefixes and
# sometimes as plain hostnames. We try the most specific form first and fall
# back to the bare host.
defp candidate_keys(@docker_hub_key) do
[
@docker_hub_key,
"index.docker.io",
"docker.io",
"https://index.docker.io/v1",
"https://index.docker.io",
"https://docker.io"
]
end
defp candidate_keys(registry) do
[
registry,
"https://" <> registry,
"http://" <> registry,
"https://" <> registry <> "/",
"http://" <> registry <> "/"
]
end
defp build_header(encoded, server_address) do
with {:ok, decoded} <- Base.decode64(encoded),
[username, password] <- String.split(decoded, ":", parts: 2) do
payload = %{
"username" => username,
"password" => password,
"serveraddress" => normalize_server_address(server_address)
}
payload
|> Jason.encode!()
|> Base.url_encode64(padding: false)
else
_ ->
Logger.debug(
"Testcontainers.Docker.Auth: could not decode auth entry for #{server_address}"
)
nil
end
end
# Docker config.json keys are often stored as URLs (e.g.
# "https://index.docker.io/v1/") but the Docker Engine API's
# `serveraddress` field expects a bare domain/IP (optionally with port) —
# podman in particular rejects a full URL with HTTP 400.
@doc false
@spec normalize_server_address(String.t()) :: String.t()
def normalize_server_address(address) when is_binary(address) do
address
|> strip_scheme()
|> strip_trailing_path()
|> canonicalize_docker_hub()
end
defp strip_scheme(address) do
case String.split(address, "://", parts: 2) do
[_scheme, rest] -> rest
[single] -> single
end
end
defp strip_trailing_path(address) do
address
|> String.split("/", parts: 2)
|> List.first()
end
defp canonicalize_docker_hub("index.docker.io"), do: "docker.io"
defp canonicalize_docker_hub(host), do: host
defp maybe_warn_cred_helper(config, registry) do
cond do
Map.has_key?(config, "credsStore") ->
Logger.debug(
"Testcontainers.Docker.Auth: credsStore present in Docker config; " <>
"credential helpers are not supported, returning nil for #{registry}"
)
is_map(Map.get(config, "credHelpers")) and
Map.has_key?(config["credHelpers"], registry) ->
Logger.debug(
"Testcontainers.Docker.Auth: credHelpers entry for #{registry} present; " <>
"credential helpers are not supported, returning nil"
)
true ->
:ok
end
end
end