Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions lib/docker/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# 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
48 changes: 45 additions & 3 deletions lib/testcontainers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Testcontainers do
alias Testcontainers.ContainerBuilder
alias Testcontainers.CopyTo
alias Testcontainers.Docker.Api
alias Testcontainers.Docker.Auth, as: DockerAuth
alias Testcontainers.DockerCompose
alias Testcontainers.PullPolicy
alias Testcontainers.Util.PropertiesParser
Expand Down Expand Up @@ -868,7 +869,7 @@ defmodule Testcontainers do
end

defp maybe_pull_image(%{pull_policy: %{always_pull: true}} = config, conn) do
case Api.pull_image(config.image, conn, auth: config.auth) do
case pull_with_fallback(config, conn) do
{:ok, _nil} -> :ok
error -> error
end
Expand All @@ -881,7 +882,7 @@ defmodule Testcontainers do
:ok

{:ok, false} ->
case Api.pull_image(config.image, conn, auth: config.auth) do
case pull_with_fallback(config, conn) do
{:ok, _nil} -> :ok
error -> error
end
Expand All @@ -894,7 +895,7 @@ defmodule Testcontainers do
defp maybe_pull_image(%{pull_policy: %{pull_condition: expr}} = config, conn)
when is_function(expr) do
with {:eval, true} <- {:eval, expr.(config, conn)},
{:ok, _nil} <- Api.pull_image(config.image, conn, auth: config.auth) do
{:ok, _nil} <- pull_with_fallback(config, conn) do
:ok
else
{:eval, reason} ->
Expand All @@ -913,6 +914,47 @@ defmodule Testcontainers do
:ok
end

# Pulls the image with the appropriate auth header. If auth was
# auto-resolved from ~/.docker/config.json (rather than set explicitly on
# the container config) and the pull fails with an HTTP 4xx, fall back to
# an anonymous pull — the daemon may reject stale or otherwise invalid
# auto-resolved credentials that we shouldn't have sent in the first place.
defp pull_with_fallback(config, conn) do
case resolve_auth(config) do
{:explicit, auth} ->
Api.pull_image(config.image, conn, auth: auth)

{:auto, auth} ->
case Api.pull_image(config.image, conn, auth: auth) do
{:error, {:http_error, status}} when status >= 400 and status < 500 ->
Logger.debug(
"Auto-resolved registry auth rejected (HTTP #{status}) for #{config.image}; retrying without auth"
)

Api.pull_image(config.image, conn, auth: nil)

result ->
result
end

:none ->
Api.pull_image(config.image, conn, auth: nil)
end
end

# Tag the auth source so pull_with_fallback knows whether it's safe to
# retry without credentials on a 4xx response.
defp resolve_auth(%{auth: auth}) when is_binary(auth) and auth != "", do: {:explicit, auth}

defp resolve_auth(%{image: image}) when is_binary(image) do
case DockerAuth.resolve(image, nil) do
nil -> :none
auth -> {:auto, auth}
end
end

defp resolve_auth(_), do: :none

defp copy_to_container(id, config, conn) do
Enum.reduce(config.copy_to, :ok, fn
copy_to, :ok ->
Expand Down
Loading
Loading