From 220a32bf38b81a8b1f2c12bd7bd8cbcd724887ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 9 Oct 2026 21:34:51 +0200 Subject: [PATCH 1/3] Fetch organization packages with Workload Identity In a GitHub Actions job with the id-token: write permission, fetching packages from an organization's repository exchanges the job's OIDC token for a short-lived repository token when no organization key or authenticated user is configured. hex_core's repository auth resolution does the exchange. Hex keeps the outcome, a token or why there is none, in memory through the new callbacks, so it's never written to the Hex config and a failed exchange isn't repeated for every package. Before the registry prefetch, the converger exchanges once per organization instead of asking to authenticate a user, and says when the job authenticated with a workload identity. After a failed exchange a fetch runs without credentials, so .netrc credentials for a mirror still apply, and a fetch the repository refuses reports why the exchange failed instead of printing a term or asking to run mix hex.user auth. Updates the vendored hex_core to 6ded3af. --- CHANGELOG.md | 2 +- lib/hex/auth.ex | 14 ++ lib/hex/registry/server.ex | 11 +- lib/hex/remote_converger.ex | 65 +++++--- lib/hex/repo.ex | 9 + lib/hex/scm.ex | 3 + lib/hex/state.ex | 3 +- lib/hex/workload_identity.ex | 42 ++++- lib/mix/tasks/hex.organization.ex | 7 + src/mix_hex_advisory.erl | 2 +- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 2 +- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_cli_auth.erl | 181 +++++++++++++++++--- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_deflate.erl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_oidc.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_policy.erl | 2 +- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 2 +- src/mix_hex_repo.erl | 2 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- test/hex/workload_identity_test.exs | 210 ++++++++++++++++++++++++ 42 files changed, 526 insertions(+), 83 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34c10acd..bce0f943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Enhancements -* Support Workload Identity, sometimes also known as Trusted Publishing, in `mix hex.publish`. In a GitHub Actions job with the `id-token: write` permission and no `HEX_API_KEY` or other Hex credential configured, the job's OIDC token is exchanged for a short-lived token that can only publish the package, so CI can publish without storing an API key. A workload identity must be configured on hex.pm first, on the package for a public package and on the organization for a package in an organization's repository. An organization's workload identity with the `write` role can also create packages +* Support Workload Identity, sometimes also known as Trusted Publishing, so a GitHub Actions job with the `id-token: write` permission can publish packages and fetch an organization's packages without storing a key. The job's OIDC token is exchanged for a short-lived token. `mix hex.publish` uses it when no `HEX_API_KEY` or other Hex credential is configured, and the token can only publish the package. Fetching packages from an organization's repository uses it when no organization key or authenticated user is configured. A workload identity must be configured on hex.pm first, on the package for a public package and on the organization for a package in an organization's repository. An organization's workload identity with the `read` role can fetch its packages, and one with the `write` role can also publish and create packages * Print warnings to standard error instead of standard output, keeping stdout clean for machine-readable output such as `mix hex.outdated --json`. Warning-colored lines that are part of a command's regular output, such as retirement notices in `mix hex.info` and the `mix deps.get` dependency listing, remain on standard output * Add `--format sarif` and `--output PATH` options to `mix hex.audit` to render the audit result as a SARIF v2.1.0 document that can be uploaded to GitHub code scanning and other SARIF consumers. Findings are anchored to the dependency's `mix.lock` entry and ignored findings are included as suppressed results. Requires OTP 27 or later * Link to the hex.pm diffs page (`https://hex.pm/diffs`) in `mix hex.outdated` now that package diffs have moved from diff.hex.pm into hex.pm diff --git a/lib/hex/auth.ex b/lib/hex/auth.ex index cc3451fd..7bf83371 100644 --- a/lib/hex/auth.ex +++ b/lib/hex/auth.ex @@ -66,6 +66,8 @@ defmodule Hex.Auth do persist_oauth_tokens: &persist_oauth_tokens/4, clear_oauth_tokens: &clear_oauth_tokens/0, organization_reauth: &organization_reauth/1, + get_workload_identity_token: &get_workload_identity_token/1, + persist_workload_identity_token: &persist_workload_identity_token/2, prompt_otp: &prompt_otp/1, get_client_id: &Hex.API.OAuth.client_id/0, should_authenticate: &should_authenticate/1 @@ -161,6 +163,18 @@ defmodule Hex.Auth do defp put_organization_reauth(token_data, organizations), do: Map.put(token_data, :organization_reauth_required, organizations) + # The outcome of a repository's Workload Identity exchange, a token or why + # there is none, is kept in memory only. Tokens expire in 15 minutes and come + # from the CI job rather than from anything the user configured, so they + # don't belong in the Hex config. + defp get_workload_identity_token(repo) do + Map.get(Hex.State.fetch!(:workload_identity_tokens), repo, :error) + end + + defp persist_workload_identity_token(repo, result) do + Hex.State.update!(:workload_identity_tokens, &Map.put(&1, repo, result)) + end + # A prompt answers :eof when there is nothing on stdin to read, which is what # an OTP challenge in CI gets. defp prompt_otp(message) do diff --git a/lib/hex/registry/server.ex b/lib/hex/registry/server.ex index 85274f62..3026deb6 100644 --- a/lib/hex/registry/server.ex +++ b/lib/hex/registry/server.ex @@ -600,7 +600,13 @@ defmodule Hex.Registry.Server do end if not (missing? or unauthorized?) or Mix.debug?() do - Hex.Utils.print_error_result(result) + case result do + {:error, {:auth_error, {:workload_identity_failed, reason}}} -> + Hex.Shell.error(Hex.WorkloadIdentity.repository_error_message(repo, reason)) + + _other -> + Hex.Utils.print_error_result(result) + end end end @@ -637,6 +643,9 @@ defmodule Hex.Registry.Server do "Set HEX_UNSAFE_REGISTRY=1 to disable this check and allow insecure package downloads." ) + {:error, {:auth_error, {:workload_identity_failed, reason}}} -> + Hex.Shell.error(Hex.WorkloadIdentity.repository_error_message(repo, reason)) + {:error, :bad_repo_name} -> Hex.Shell.error( "The configured repository name for your dependency #{Hex.Utils.package_name(repo, package)} does not " <> diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 45d41771..3f5d90d6 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -996,37 +996,50 @@ defmodule Hex.RemoteConverger do # ahead of use the session; an API key would resolve here and leave the # session to be refreshed mid-fetch, past the point where anything can be # asked. + # + # Without a session, a CI job that can authenticate with a workload identity + # fetches with that instead, so there is nothing to ask for. It is exchanged + # here so the output says once that the job authenticated with it. @doc false def check_and_refresh_auth([]), do: :ok - def check_and_refresh_auth(_organizations) do - if Hex.State.fetch!(:offline) do - :ok - else - config = Hex.API.Client.config([]) + def check_and_refresh_auth(organizations) do + cond do + Hex.State.fetch!(:offline) -> + :ok - case Hex.Auth.with_session_api(:read, config, fn _config -> :ok end, auth_inline: true) do - :ok -> - :ok + not Hex.OAuth.has_tokens?() and Hex.WorkloadIdentity.available?() -> + Hex.WorkloadIdentity.repository_auth(organizations) - {:error, {:auth_error, :auth_declined}} -> - Hex.Shell.warn( - "Private packages will not be available. " <> - "Run `mix hex.user auth` to authenticate." - ) - - {:error, {:auth_error, :token_refresh_unavailable}} -> - Hex.Shell.warn( - "Could not reach Hex to renew your authentication. " <> - "Private packages will not be available." - ) - - {:error, _reason} -> - Hex.Shell.warn( - "Authentication failed. Private packages will not be available. " <> - "Run `mix hex.user auth` to authenticate." - ) - end + true -> + check_and_refresh_session() + end + end + + defp check_and_refresh_session do + config = Hex.API.Client.config([]) + + case Hex.Auth.with_session_api(:read, config, fn _config -> :ok end, auth_inline: true) do + :ok -> + :ok + + {:error, {:auth_error, :auth_declined}} -> + Hex.Shell.warn( + "Private packages will not be available. " <> + "Run `mix hex.user auth` to authenticate." + ) + + {:error, {:auth_error, :token_refresh_unavailable}} -> + Hex.Shell.warn( + "Could not reach Hex to renew your authentication. " <> + "Private packages will not be available." + ) + + {:error, _reason} -> + Hex.Shell.warn( + "Authentication failed. Private packages will not be available. " <> + "Run `mix hex.user auth` to authenticate." + ) end end diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index dcc2d8a3..2423de26 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -245,6 +245,15 @@ defmodule Hex.Repo do ) end + # Resolves the repository's credentials without making a request, so one + # that has to be exchanged is exchanged once, ahead of the parallel fetches + # that use it. + def resolve_auth(repo) do + repo_config = get_repo(repo) + config = build_hex_core_config(repo_config, repo) + :mix_hex_cli_auth.resolve_repo_auth(config) + end + def get_public_key(repo_config) when is_map(repo_config) do config = build_hex_core_config(repo_config, "") diff --git a/lib/hex/scm.ex b/lib/hex/scm.ex index 16efb362..f59577d8 100644 --- a/lib/hex/scm.ex +++ b/lib/hex/scm.ex @@ -515,6 +515,9 @@ defmodule Hex.SCM do {:error, reason} + {:error, {:auth_error, {:workload_identity_failed, reason}}} -> + {:error, Hex.WorkloadIdentity.repository_error_message(repo, reason)} + {:error, reason} -> {:error, "Request failed (#{inspect(reason)})"} end diff --git a/lib/hex/state.ex b/lib/hex/state.ex index bf522164..63856e23 100644 --- a/lib/hex/state.ex +++ b/lib/hex/state.ex @@ -208,7 +208,8 @@ defmodule Hex.State do active_policy: {:computed, nil}, policy_filtered_versions: {:computed, []}, policy_locked_versions: {:computed, %{}}, - unsatisfiable_requirements: {:computed, MapSet.new()} + unsatisfiable_requirements: {:computed, MapSet.new()}, + workload_identity_tokens: {:computed, %{}} }) end diff --git a/lib/hex/workload_identity.ex b/lib/hex/workload_identity.ex index 5c04c21e..874f2dd4 100644 --- a/lib/hex/workload_identity.ex +++ b/lib/hex/workload_identity.ex @@ -21,10 +21,50 @@ defmodule Hex.WorkloadIdentity do [] {:error, reason} -> - Mix.raise("Workload Identity authentication failed, " <> describe(reason, scope)) + Mix.raise(error_message(reason, scope)) end end + @doc """ + Whether the job can authenticate with a workload identity. + """ + def available? do + :mix_hex_oidc.detect_provider() != :none + end + + @doc """ + Exchanges the CI job's OIDC token for each organization's repository token. + + The fetches from the repository reuse the token, or the failure. A failure + is reported by each fetch the repository refuses without credentials. + """ + def repository_auth(organizations) do + Enum.each(organizations, fn organization -> + case Hex.Repo.resolve_auth("hexpm:#{organization}") do + {:ok, _repo_key, _auth_context} -> + Hex.Shell.info( + "Authenticated to the #{organization} organization with Workload Identity" + ) + + _other -> + :ok + end + end) + end + + @doc """ + The message for a failed exchange for an organization's repository. + """ + def repository_error_message("hexpm:" <> organization, reason) do + error_message(reason, "repository:#{organization}") <> + ". To fetch without Workload Identity, authenticate with " <> + "`mix hex.organization auth #{organization} --key KEY`" + end + + defp error_message(reason, scope) do + "Workload Identity authentication failed, " <> describe(reason, scope) + end + defp describe({:oidc_audience_failed, response}, _scope) do "could not fetch the OIDC audience from Hex: " <> describe_response(response) end diff --git a/lib/mix/tasks/hex.organization.ex b/lib/mix/tasks/hex.organization.ex index 92d19c4c..5d8bc6d6 100644 --- a/lib/mix/tasks/hex.organization.ex +++ b/lib/mix/tasks/hex.organization.ex @@ -17,6 +17,13 @@ defmodule Mix.Tasks.Hex.Organization do For CI, generate an organization key with `mix hex.organization key ORGANIZATION generate` and pass it with `mix hex.organization auth ORGANIZATION --key KEY`. + A GitHub Actions job with the `id-token: write` permission can fetch the + organization's packages without a key, with Workload Identity, sometimes also + known as "Trusted Publishing". Configure a workload identity with the `read` + or `write` role for the organization on hex.pm. When neither an organization + key nor an authenticated user is configured, the job's OIDC token is exchanged + for a short-lived token that can fetch the organization's packages. + > #### Deprecation {: .warning} > > Authorizing an organization without `--key` is deprecated and will be removed. diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 17b20898..1c2b12f6 100644 --- a/src/mix_hex_advisory.erl +++ b/src/mix_hex_advisory.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Display-time deduplication of security advisories. diff --git a/src/mix_hex_api.erl b/src/mix_hex_api.erl index 4a5d2194..f8bc429f 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index db7866c2..2ddb6e52 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Authentication. diff --git a/src/mix_hex_api_key.erl b/src/mix_hex_api_key.erl index c3921679..a2e19400 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Keys. diff --git a/src/mix_hex_api_oauth.erl b/src/mix_hex_api_oauth.erl index e17e8de6..97eeb081 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - OAuth. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index 91cb76b7..5cf98149 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Organizations. diff --git a/src/mix_hex_api_organization_member.erl b/src/mix_hex_api_organization_member.erl index ee3bce20..0e873608 100644 --- a/src/mix_hex_api_organization_member.erl +++ b/src/mix_hex_api_organization_member.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Organization Members. diff --git a/src/mix_hex_api_package.erl b/src/mix_hex_api_package.erl index 93b43486..e4c7741f 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Packages. diff --git a/src/mix_hex_api_package_owner.erl b/src/mix_hex_api_package_owner.erl index a398151b..5044dde8 100644 --- a/src/mix_hex_api_package_owner.erl +++ b/src/mix_hex_api_package_owner.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Package Owners. diff --git a/src/mix_hex_api_release.erl b/src/mix_hex_api_release.erl index 81787a39..7c28800f 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Releases. diff --git a/src/mix_hex_api_short_url.erl b/src/mix_hex_api_short_url.erl index 652e3fbe..3a143542 100644 --- a/src/mix_hex_api_short_url.erl +++ b/src/mix_hex_api_short_url.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Short URLs. diff --git a/src/mix_hex_api_user.erl b/src/mix_hex_api_user.erl index 4aa0d4de..a524ab6c 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex HTTP API - Users. diff --git a/src/mix_hex_cli_auth.erl b/src/mix_hex_cli_auth.erl index b0f1cf67..0078a3cc 100644 --- a/src/mix_hex_cli_auth.erl +++ b/src/mix_hex_cli_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Authentication handling with callback functions for build-tool-specific operations. @@ -45,6 +45,20 @@ %% %% that is the build tool's job. %% organization_reauth => fun(([map()]) -> ok), %% +%% %% Outcome of the last Workload Identity exchange for a repository +%% %% (optional, both or neither). Hex.pm organization repositories are +%% %% only fetched with a workload identity when the build tool keeps the +%% %% outcome, so concurrent and later requests reuse a token, or the +%% %% failure, instead of each exchanging a new OIDC token. Keep it in +%% %% memory for the rest of the run, the token expires in minutes. +%% get_workload_identity_token => fun((RepoName :: binary()) -> +%% {ok, #{access_token := binary(), expires_at := integer()}} +%% | {error, workload_identity_error()} +%% | error), +%% persist_workload_identity_token => fun((RepoName :: binary(), +%% {ok, #{access_token := binary(), expires_at := integer()}} +%% | {error, workload_identity_error()}) -> ok), +%% %% %% User interaction %% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), %% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()), @@ -69,6 +83,7 @@ %%
  • Per-repo `auth_key' with optional OAuth exchange (default true for hex.pm)
  • %%
  • Parent repo `auth_key'
  • %%
  • Global OAuth token
  • +%%
  • Workload Identity token (for "hexpm:org" organizations)
  • %% %% %% == OAuth Exchange == @@ -92,9 +107,15 @@ %% == Workload Identity == %% %% Workload Identity, sometimes also known as "Trusted Publishing", lets a -%% supported CI job (currently GitHub Actions) publish without a stored API key. -%% `workload_identity_auth/2' exchanges the job's OIDC token for API auth -%% scoped to one package. See its documentation for details. +%% supported CI job (currently GitHub Actions) publish and fetch without a +%% stored key. `workload_identity_auth/2' exchanges the job's OIDC token for API +%% auth scoped to one package. See its documentation for details. +%% +%% A repository request to a Hex.pm organization ("hexpm:org") that resolves +%% no other credentials exchanges the job's OIDC token for a token scoped to +%% the organization's repository, when the build tool provides the +%% `get_workload_identity_token' and `persist_workload_identity_token' +%% callbacks. A failed exchange isn't repeated in the same run. -module(mix_hex_cli_auth). -export([ @@ -151,6 +172,12 @@ ), clear_oauth_tokens => fun(() -> ok), organization_reauth => fun((Organizations :: [map()]) -> ok), + get_workload_identity_token => fun( + (RepoName :: binary()) -> {ok, oauth_tokens()} | {error, workload_identity_error()} | error + ), + persist_workload_identity_token => fun( + (RepoName :: binary(), {ok, oauth_tokens()} | {error, workload_identity_error()}) -> ok + ), prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()), get_client_id := fun(() -> binary()) @@ -186,6 +213,7 @@ | {auth_error, device_auth_timeout} | {auth_error, device_auth_denied} | {auth_error, oauth_exchange_failed} + | {auth_error, {workload_identity_failed, workload_identity_error()}} | {auth_error, term()}. -type auth_context() :: #{ @@ -333,13 +361,18 @@ with_repo(BaseConfig, Fun) -> %%
  • `auth_key' from `get_auth_config' when `trusted' is true and `oauth_exchange' is true - exchange for OAuth token
  • %%
  • `auth_key' from `get_auth_config' when `trusted' is true - use directly
  • %%
  • Global OAuth token from `get_oauth_tokens' callback for Hex.pm repositories
  • +%%
  • Workload Identity token for Hex.pm organization repositories, when the +%% build tool provides the Workload Identity callbacks and the CI job can +%% issue OIDC tokens. After a failed exchange the function runs without +%% credentials when `optional' is true, and a 401 or 403 is returned as +%% `{error, {auth_error, {workload_identity_failed, Reason}}}'.
  • %%
  • No auth when `optional' is true (with retry on 401)
  • %%
  • Prompt via `should_authenticate' when `auth_inline' is true
  • %% %% %% A resolved token the server answers with a `token_expired' 401 is renewed at -%% its source (a per-repo token is exchanged again, the global token is -%% refreshed) and the request is run once more. +%% its source (a per-repo or Workload Identity token is exchanged again, the +%% global token is refreshed) and the request is run once more. %% %% The repository name is taken from the config (`repo_name' or `repo_organization'). %% @@ -383,10 +416,24 @@ with_repo(BaseConfig, Fun, Opts) -> {error, {auth_error, Reason}} when Optional =:= true, ?IS_REFRESH_FAILURE(Reason) -> %% Token refresh failed but auth is optional, fall back to no credentials execute_optional_with_retry(repo, BaseConfig, Fun, AuthInline, Opts); + {error, {auth_error, {workload_identity_failed, _Reason}}} = Error when Optional =:= true -> + execute_without_workload_identity(BaseConfig, Fun, Error); {error, _} = Error -> Error end. +%% @private +%% The workload identity was only picked up from the CI job, so a failed +%% exchange leaves the request to run the way it does outside CI: a mirror +%% that authenticates another way still works. A refusal is answered with why +%% the exchange failed. +execute_without_workload_identity(BaseConfig, Fun, Error) -> + case Fun(BaseConfig) of + {ok, {Status, _Headers, _Body}} when Status =:= 401; Status =:= 403 -> Error; + {ok, {Status, _Headers}} when Status =:= 401; Status =:= 403 -> Error; + Other -> Other + end. + %% @private %% Extract repository name from config. -spec repo_name(mix_hex_core:config()) -> binary(). @@ -520,9 +567,11 @@ execute_repo_with_retry(BaseConfig, Fun, RepoKey) -> %% @private renew_repo_auth_and_retry(BaseConfig, Fun, RepoKey, Response) -> - case resolve_repo_auth(BaseConfig, true) of + case resolve_repo_auth(BaseConfig, {rejected, RepoKey}) of {ok, NewRepoKey, _AuthContext} when is_binary(NewRepoKey), NewRepoKey =/= RepoKey -> Fun(BaseConfig#{repo_key => NewRepoKey}); + {error, {auth_error, {workload_identity_failed, _Reason}}} = Error -> + Error; _Other -> Response end. @@ -575,13 +624,20 @@ workload_identity_auth(Config, Scope) -> _NoUsableCredentials -> case mix_hex_oidc:detect_provider() of {ok, Provider} -> - authenticate_workload_identity(Config, Provider, Scope); + case authenticate_workload_identity(Config, Provider, Scope) of + {ok, #{access_token := AccessToken}} -> + {ok, <<"Bearer ", AccessToken/binary>>}; + {error, _Reason} = Error -> + Error + end; none -> none end end. %% @private +-spec authenticate_workload_identity(mix_hex_core:config(), mix_hex_oidc:provider(), binary()) -> + {ok, oauth_tokens()} | {error, workload_identity_error()}. authenticate_workload_identity(Config, Provider, Scope) -> case mix_hex_api_oauth:oidc_audience(Config) of {ok, {200, _Headers, #{<<"audience">> := Audience}}} when is_binary(Audience) -> @@ -598,8 +654,13 @@ authenticate_workload_identity(Config, Provider, Scope) -> %% @private exchange_workload_identity_token(Config, OidcToken, Scope) -> case mix_hex_api_oauth:jwt_bearer_token(Config, OidcToken, Scope) of - {ok, {200, _Headers, #{<<"access_token">> := AccessToken}}} when is_binary(AccessToken) -> - {ok, <<"Bearer ", AccessToken/binary>>}; + {ok, + {200, _Headers, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} when + is_binary(AccessToken), is_integer(ExpiresIn) + -> + {ok, #{ + access_token => AccessToken, expires_at => erlang:system_time(second) + ExpiresIn + }}; Response -> {error, {token_exchange_failed, Response}} end. @@ -676,15 +737,17 @@ resolve_api_auth(_Permission, Config) -> %% 2. trusted + auth_key + oauth_exchange => exchange for OAuth token %% 3. trusted + auth_key => use directly %% 4. trusted Hex.pm or child repository + global OAuth tokens => use those -%% 5. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) +%% 5. trusted Hex.pm child repository + Workload Identity => use or exchange +%% 6. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) -spec resolve_repo_auth(mix_hex_core:config()) -> {ok, binary(), auth_context()} | no_auth | {error, auth_error()}. resolve_repo_auth(Config) -> resolve_repo_auth(Config, false). %% @private -%% Renew says the credential we already have was rejected, so a stored token -%% that has not run out of time is exchanged or refreshed anyway. +%% Renew is `{rejected, RepoKey}' when the server rejected the credential we +%% have, so a stored token that has not run out of time is exchanged or +%% refreshed anyway, and `false' otherwise. resolve_repo_auth(#{repo_key := RepoKey}, _Renew) when is_binary(RepoKey) -> %% repo_key already in config, pass through directly {ok, RepoKey, #{has_refresh_token => false}}; @@ -736,10 +799,16 @@ do_resolve_repo_auth(RepoName, LookupRepo, Config, Renew, Lock) -> do_resolve_repo_auth(RepoName, ParentName, Config, Renew, Lock); _ -> %% 6. trusted Hex.pm or child repository + global OAuth tokens => use those - resolve_global_oauth_for_repo(RepoName, Config, Renew) + case resolve_global_oauth_for_repo(RepoName, Config, Renew) of + no_auth -> + %% 7. trusted Hex.pm child repository + Workload Identity + resolve_workload_identity_for_repo(RepoName, Config, Renew, Lock); + Result -> + Result + end end; _ -> - %% 7. Not trusted, no auth + %% 8. Not trusted, no auth no_auth end. @@ -752,7 +821,7 @@ resolve_global_oauth_for_repo(_RepoName, _Config, _Renew) -> no_auth. resolve_global_oauth_for_repo(Config, Renew) -> - case resolve_oauth_token_with_context(Config, Renew) of + case resolve_oauth_token_with_context(Config, Renew =/= false) of {ok, Token, AuthContext} -> {ok, Token, AuthContext}; {error, no_auth} -> @@ -761,6 +830,70 @@ resolve_global_oauth_for_repo(Config, Renew) -> Error end. +%% @private +%% A Hex.pm organization's repository fetched from a CI job. The build tool +%% keeps the outcome, so a token is reused until it is about to expire, and +%% only an exchange takes the repo lock, like resolve_repo_oauth_token/6. A +%% renewal uses a token another caller exchanged while this one waited for the +%% lock instead of exchanging again. A failure is kept too: each failed +%% exchange counts against the CI job's limit at Hex, and the next one would +%% fail the same way. +resolve_workload_identity_for_repo( + <<"hexpm:", Organization/binary>> = RepoName, Config, Renew, Lock +) -> + case workload_identity_provider(Config) of + {ok, Provider} -> + case call_callback(Config, get_workload_identity_token, [RepoName]) of + {ok, #{access_token := AccessToken, expires_at := ExpiresAt}} -> + BearerToken = <<"Bearer ", AccessToken/binary>>, + case Renew =:= {rejected, BearerToken} orelse is_token_expired(ExpiresAt) of + false -> + {ok, BearerToken, #{has_refresh_token => false}}; + true -> + exchange_workload_identity_for_repo( + RepoName, Organization, Config, Provider, Lock + ) + end; + {error, Reason} -> + {error, {auth_error, {workload_identity_failed, Reason}}}; + error -> + exchange_workload_identity_for_repo( + RepoName, Organization, Config, Provider, Lock + ) + end; + none -> + no_auth + end; +resolve_workload_identity_for_repo(_RepoName, _Config, _Renew, _Lock) -> + no_auth. + +%% @private +%% Without somewhere to keep the token every request would exchange a new OIDC +%% token, so a build tool that provides no Workload Identity callbacks doesn't +%% get Workload Identity for repositories. +workload_identity_provider(#{cli_auth_callbacks := Callbacks}) -> + case + is_map_key(get_workload_identity_token, Callbacks) andalso + is_map_key(persist_workload_identity_token, Callbacks) + of + true -> mix_hex_oidc:detect_provider(); + false -> none + end. + +%% @private +exchange_workload_identity_for_repo(_RepoName, _Organization, _Config, _Provider, unlocked) -> + needs_lock; +exchange_workload_identity_for_repo(RepoName, Organization, Config, Provider, locked) -> + Scope = <<"repository:", Organization/binary>>, + Result = authenticate_workload_identity(exchange_config(Config), Provider, Scope), + ok = call_callback(Config, persist_workload_identity_token, [RepoName, Result]), + case Result of + {ok, #{access_token := AccessToken}} -> + {ok, <<"Bearer ", AccessToken/binary>>, #{has_refresh_token => false}}; + {error, Reason} -> + {error, {auth_error, {workload_identity_failed, Reason}}} + end. + %% @private %% Resolve repo OAuth token: use if valid, re-exchange if expiring or rejected. resolve_repo_oauth_token( @@ -771,7 +904,7 @@ resolve_repo_oauth_token( Renew, Lock ) -> - case {Renew orelse is_token_expired(ExpiresAt), Lock} of + case {Renew =/= false orelse is_token_expired(ExpiresAt), Lock} of {false, _} -> %% Token is still valid, use it BearerToken = <<"Bearer ", AccessToken/binary>>, @@ -788,11 +921,7 @@ resolve_repo_oauth_token( %% Persists the token with the repo name for per-repo token storage. exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) -> ClientId = call_callback(Config, get_client_id, []), - ExchangeConfig = - case maps:get(oauth_exchange_url, Config, undefined) of - undefined -> Config; - OAuthUrl -> Config#{api_url => OAuthUrl} - end, + ExchangeConfig = exchange_config(Config), case mix_hex_api_oauth:client_credentials_token(ExchangeConfig, ClientId, AuthKey, Scope) of {ok, {200, _, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} -> Tokens = #{ @@ -808,6 +937,14 @@ exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) -> {error, {auth_error, oauth_exchange_failed}} end. +%% @private +%% Repository tokens are exchanged at `oauth_exchange_url' when it is set. +exchange_config(Config) -> + case maps:get(oauth_exchange_url, Config, undefined) of + undefined -> Config; + OAuthUrl -> Config#{api_url => OAuthUrl} + end. + %% @private get_parent_repo_key(Config, RepoName, KeyType) -> case binary:split(RepoName, <<":">>) of diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index 6fa81c14..b63d0a32 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% `hex_core' entrypoint module. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index 43997d2d..a1d103e0 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually -define(HEX_CORE_VERSION, "0.19.0"). diff --git a/src/mix_hex_deflate.erl b/src/mix_hex_deflate.erl index 4b96f497..602462df 100644 --- a/src/mix_hex_deflate.erl +++ b/src/mix_hex_deflate.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Pure-Erlang raw DEFLATE (RFC 1951) encoder. The output only depends on the diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index d845010a..6e51084f 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% This file is a copy of erl_tar.erl from OTP with the following modifications: %% 1. Module renamed from erl_tar to mix_hex_erl_tar diff --git a/src/mix_hex_erl_tar.hrl b/src/mix_hex_erl_tar.hrl index 6bef8d4c..a1c13be1 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% This file is a copy of erl_tar.hrl from OTP without modifications. %% diff --git a/src/mix_hex_http.erl b/src/mix_hex_http.erl index 26ff21f8..6e54bb1c 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index e1f35fdb..1b571e29 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% httpc-based implementation of {@link mix_hex_http} contract. diff --git a/src/mix_hex_licenses.erl b/src/mix_hex_licenses.erl index c0d8914e..ca0a983d 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_oidc.erl b/src/mix_hex_oidc.erl index c74c15b3..271c6807 100644 --- a/src/mix_hex_oidc.erl +++ b/src/mix_hex_oidc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% OIDC token issuance for CI providers, used by Workload Identity. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index 7e1aded6..c33f8cc0 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_package.erl b/src/mix_hex_pb_package.erl index f1d37f27..462d542b 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_policy.erl b/src/mix_hex_pb_policy.erl index d24f434a..fb077e32 100644 --- a/src/mix_hex_pb_policy.erl +++ b/src/mix_hex_pb_policy.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_signed.erl b/src/mix_hex_pb_signed.erl index e7d52600..ea998280 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_versions.erl b/src/mix_hex_pb_versions.erl index 21b73e2f..58603a4e 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_registry.erl b/src/mix_hex_registry.erl index b2e07bed..c4796b03 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index 40e5aa3a..7b72c91f 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Repo API. diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index c723841c..7ce202f9 100644 --- a/src/mix_hex_safe_binary_to_term.erl +++ b/src/mix_hex_safe_binary_to_term.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @hidden %% Safe deserialization of Erlang terms from binary. diff --git a/src/mix_hex_tarball.erl b/src/mix_hex_tarball.erl index 91eb1663..263460ad 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %% @doc %% Functions for creating and unpacking Hex tarballs. diff --git a/src/mix_safe_erl_term.xrl b/src/mix_safe_erl_term.xrl index 974b910d..a8852e5e 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (69f91eb), do not edit manually +%% Vendored from hex_core v0.19.0 (6ded3af), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. diff --git a/test/hex/workload_identity_test.exs b/test/hex/workload_identity_test.exs index 927d9846..7deb64e3 100644 --- a/test/hex/workload_identity_test.exs +++ b/test/hex/workload_identity_test.exs @@ -30,4 +30,214 @@ defmodule Hex.WorkloadIdentityTest do assert Hex.WorkloadIdentity.auth!("hexpm", "foo") == [] end end + + describe "fetching from an organization's repository" do + @refusal {403, + %{ + "error" => "access_denied", + "error_description" => "No matching workload identity" + }} + + @refusal_message "Workload Identity authentication failed, Hex did not grant a token for " <> + "repository:acme: access_denied: No matching workload identity (HTTP 403). " <> + "To fetch without Workload Identity, authenticate with " <> + "`mix hex.organization auth acme --key KEY`" + + setup do + bypass = Bypass.open() + Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") + Hex.State.update!(:repos, &put_in(&1["hexpm"].url, "http://localhost:#{bypass.port}/repo")) + + System.put_env( + "ACTIONS_ID_TOKEN_REQUEST_URL", + "http://localhost:#{bypass.port}/github/token?api-version=2.0" + ) + + {:ok, bypass: bypass} + end + + @tag :requires_json + test "exchanges once per organization ahead of the fetches", %{bypass: bypass} do + in_tmp(fn -> + set_home_cwd() + stub_repository(bypass) + + assert Hex.RemoteConverger.check_and_refresh_auth(["acme"]) == :ok + + assert_received {:exchange, exchange} + + assert exchange == %{ + "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion" => "oidc_token", + "scope" => "repository:acme" + } + + assert_received {:mix_shell, :info, + ["Authenticated to the acme organization with Workload Identity"]} + + refute_received {:mix_shell, :yes?, _question} + + fetch_tarballs() + refute_received {:exchange, _} + end) + end + + @tag :requires_json + test "exchanges once when fetching without the preflight", %{bypass: bypass} do + in_tmp(fn -> + set_home_cwd() + stub_repository(bypass) + + fetch_tarballs() + + assert_received {:exchange, %{"scope" => "repository:acme"}} + refute_received {:exchange, _} + refute Map.has_key?(Hex.Config.read_repos(Hex.Config.read()), "hexpm:acme") + end) + end + + @tag :requires_json + test "a fetch the repository refuses reports the refused exchange", %{bypass: bypass} do + stub_repository(bypass, exchange: @refusal, repository: {401, ""}) + + assert Hex.RemoteConverger.check_and_refresh_auth(["acme"]) == :ok + refute_received {:mix_shell, :info, _message} + + assert {:error, {:auth_error, {:workload_identity_failed, reason}}} = + Hex.Repo.get_tarball("hexpm:acme", "foo", "1.0.0") + + assert Hex.WorkloadIdentity.repository_error_message("hexpm:acme", reason) == + @refusal_message + + assert_received {:repository, "tarballs/foo-1.0.0.tar", []} + assert_received {:exchange, _exchange} + refute_received {:exchange, _exchange} + end + + @tag :requires_json + test "a refused exchange leaves the request to .netrc credentials", %{bypass: bypass} do + on_exit(fn -> System.delete_env("NETRC") end) + + in_tmp(fn -> + File.write!(".netrc", """ + machine localhost + login mirror-user + password mirror-password + """) + + System.put_env("NETRC", Path.join(File.cwd!(), ".netrc")) + stub_repository(bypass, exchange: @refusal) + + assert {:ok, {200, _, "tarball"}} = Hex.Repo.get_tarball("hexpm:acme", "foo", "1.0.0") + + basic = "Basic " <> Base.encode64("mirror-user:mirror-password") + assert_received {:repository, "tarballs/foo-1.0.0.tar", [^basic]} + end) + end + + @tag :requires_json + test "parallel registry fetches share one refused exchange", %{bypass: bypass} do + stub_repository(bypass, exchange: @refusal, repository: {401, ""}) + Hex.State.put(:shell_process, self()) + + Hex.Registry.Server.open( + check_version: false, + registry_path: tmp_path("workload_identity.ets") + ) + + packages = ["foo", "bar", "baz"] + + Hex.Registry.Server.prefetch(Enum.map(packages, &{"hexpm:acme", &1})) + + for package <- packages do + assert Hex.Registry.Server.versions("hexpm:acme", package) == :error + + assert_received {:mix_shell, :error, + [ + "Failed to fetch record for hexpm:acme/" <> + ^package <> " from registry" + ]} + + assert_received {:mix_shell, :error, [@refusal_message]} + end + + assert_received {:exchange, _exchange} + refute_received {:exchange, _exchange} + end + + test "a stored user session takes precedence", %{bypass: bypass} do + in_tmp(fn -> + set_home_cwd() + stub_repository(bypass) + + Hex.OAuth.store_token(%{ + access_token: "user_token", + expires_at: System.os_time(:second) + 3600 + }) + + assert Hex.RemoteConverger.check_and_refresh_auth(["acme"]) == :ok + assert {:ok, {200, _, "tarball"}} = Hex.Repo.get_tarball("hexpm:acme", "foo", "1.0.0") + + assert_received {:repository, "tarballs/foo-1.0.0.tar", ["Bearer user_token"]} + refute_received {:exchange, _} + end) + end + end + + defp fetch_tarballs do + assert {:ok, {200, _, "tarball"}} = Hex.Repo.get_tarball("hexpm:acme", "foo", "1.0.0") + assert {:ok, {200, _, "tarball"}} = Hex.Repo.get_tarball("hexpm:acme", "bar", "1.0.0") + + assert_received {:repository, "tarballs/foo-1.0.0.tar", ["Bearer minted_token"]} + assert_received {:repository, "tarballs/bar-1.0.0.tar", ["Bearer minted_token"]} + end + + defp stub_repository(bypass, opts \\ []) do + test_pid = self() + + exchange = + Keyword.get( + opts, + :exchange, + {200, + %{ + "access_token" => "minted_token", + "token_type" => "bearer", + "expires_in" => 900, + "scope" => "repository:acme" + }} + ) + + {repository_status, repository_body} = Keyword.get(opts, :repository, {200, "tarball"}) + + Bypass.expect(bypass, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + auth = Plug.Conn.get_req_header(conn, "authorization") + + case {conn.method, conn.request_path} do + {"GET", "/api/oidc/audience"} -> + erlang_resp(conn, 200, %{"audience" => "hexpm"}) + + {"GET", "/github/token"} -> + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, ~s({"count":1,"value":"oidc_token"})) + + {"POST", "/api/oauth/token"} -> + send(test_pid, {:exchange, :erlang.binary_to_term(body)}) + {status, payload} = exchange + erlang_resp(conn, status, payload) + + {"GET", "/repo/repos/acme/" <> path} -> + send(test_pid, {:repository, path, auth}) + Plug.Conn.resp(conn, repository_status, repository_body) + end + end) + end + + defp erlang_resp(conn, status, payload) do + conn + |> Plug.Conn.put_resp_content_type("application/vnd.hex+erlang") + |> Plug.Conn.resp(status, :erlang.term_to_binary(payload)) + end end From b7ddfa4009908e6c733b4c07fb92de9f37ffd409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 9 Oct 2026 22:26:38 +0200 Subject: [PATCH 2/3] Compile the registry fetch test on Elixir 1.12 to 1.15 Those versions reject a pinned variable on the left of <> in a match, so the expected message is built before the assertion. --- test/hex/workload_identity_test.exs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/hex/workload_identity_test.exs b/test/hex/workload_identity_test.exs index 7deb64e3..fcb3a476 100644 --- a/test/hex/workload_identity_test.exs +++ b/test/hex/workload_identity_test.exs @@ -152,12 +152,8 @@ defmodule Hex.WorkloadIdentityTest do for package <- packages do assert Hex.Registry.Server.versions("hexpm:acme", package) == :error - assert_received {:mix_shell, :error, - [ - "Failed to fetch record for hexpm:acme/" <> - ^package <> " from registry" - ]} - + message = "Failed to fetch record for hexpm:acme/#{package} from registry" + assert_received {:mix_shell, :error, [^message]} assert_received {:mix_shell, :error, [@refusal_message]} end From a2c5f43dce0229dd36ef8c0257ce7158cffea819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 10 Oct 2026 13:57:39 +0200 Subject: [PATCH 3/3] Reword the Workload Identity docs Name who exchanges the OIDC token instead of using the passive voice in the mix hex.publish and mix hex.organization docs and the changelog entry. The facts are unchanged. --- CHANGELOG.md | 2 +- lib/mix/tasks/hex.organization.ex | 6 +++--- lib/mix/tasks/hex.publish.ex | 19 ++++++++++--------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce0f943..dd795516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Enhancements -* Support Workload Identity, sometimes also known as Trusted Publishing, so a GitHub Actions job with the `id-token: write` permission can publish packages and fetch an organization's packages without storing a key. The job's OIDC token is exchanged for a short-lived token. `mix hex.publish` uses it when no `HEX_API_KEY` or other Hex credential is configured, and the token can only publish the package. Fetching packages from an organization's repository uses it when no organization key or authenticated user is configured. A workload identity must be configured on hex.pm first, on the package for a public package and on the organization for a package in an organization's repository. An organization's workload identity with the `read` role can fetch its packages, and one with the `write` role can also publish and create packages +* Support Workload Identity, sometimes also known as Trusted Publishing, so a GitHub Actions job with the `id-token: write` permission can publish packages and fetch an organization's packages without storing a key. When no `HEX_API_KEY` or other Hex credential is configured, `mix hex.publish` exchanges the job's OIDC token for a short-lived token that can only publish the package. When no organization key or authenticated user is configured, fetching packages from an organization's repository exchanges it for a short-lived token that can fetch them. Configure a workload identity on hex.pm first, on the package for a public package and on the organization for a package in an organization's repository. An organization's workload identity with the `read` role can fetch its packages, and one with the `write` role can also publish and create packages * Print warnings to standard error instead of standard output, keeping stdout clean for machine-readable output such as `mix hex.outdated --json`. Warning-colored lines that are part of a command's regular output, such as retirement notices in `mix hex.info` and the `mix deps.get` dependency listing, remain on standard output * Add `--format sarif` and `--output PATH` options to `mix hex.audit` to render the audit result as a SARIF v2.1.0 document that can be uploaded to GitHub code scanning and other SARIF consumers. Findings are anchored to the dependency's `mix.lock` entry and ignored findings are included as suppressed results. Requires OTP 27 or later * Link to the hex.pm diffs page (`https://hex.pm/diffs`) in `mix hex.outdated` now that package diffs have moved from diff.hex.pm into hex.pm diff --git a/lib/mix/tasks/hex.organization.ex b/lib/mix/tasks/hex.organization.ex index 5d8bc6d6..efa5f104 100644 --- a/lib/mix/tasks/hex.organization.ex +++ b/lib/mix/tasks/hex.organization.ex @@ -18,11 +18,11 @@ defmodule Mix.Tasks.Hex.Organization do and pass it with `mix hex.organization auth ORGANIZATION --key KEY`. A GitHub Actions job with the `id-token: write` permission can fetch the - organization's packages without a key, with Workload Identity, sometimes also + organization's packages without a key, using Workload Identity, sometimes also known as "Trusted Publishing". Configure a workload identity with the `read` or `write` role for the organization on hex.pm. When neither an organization - key nor an authenticated user is configured, the job's OIDC token is exchanged - for a short-lived token that can fetch the organization's packages. + key nor an authenticated user is configured, Hex exchanges the job's OIDC + token for a short-lived token that can fetch the organization's packages. > #### Deprecation {: .warning} > diff --git a/lib/mix/tasks/hex.publish.ex b/lib/mix/tasks/hex.publish.ex index cb27ad80..8bdd5ef7 100644 --- a/lib/mix/tasks/hex.publish.ex +++ b/lib/mix/tasks/hex.publish.ex @@ -33,15 +33,16 @@ defmodule Mix.Tasks.Hex.Publish do ## Workload Identity - Workload Identity is sometimes also known as "Trusted Publishing". In a GitHub - Actions job with the `id-token: write` permission, and when no `HEX_API_KEY` or - other Hex credential is configured, the job's OIDC token is exchanged for a - short-lived token that can only publish this package. Configure a workload - identity on hex.pm first. A public package's workload identity is configured - on the package, so the package must already exist and its first release has - to be published with a regular account. A package in an organization's - repository uses the organization's workload identities, and one with the - `write` role can also create the package. + Workload Identity, sometimes also known as "Trusted Publishing", lets a GitHub + Actions job publish without storing an API key. When the job has the + `id-token: write` permission and no `HEX_API_KEY` or other Hex credential is + configured, `mix hex.publish` exchanges the job's OIDC token for a short-lived + token that can only publish this package. Configure a workload identity on + hex.pm first. A public package's workload identity is configured on the + package, so the package must already exist and its first release has to be + published with a regular account. A package in an organization's repository + uses the organization's workload identities, and one with the `write` role can + also create the package. ## Reverting a package