hex_cli_auth (hex_core v0.20.0)

View Source

Authentication handling with callback functions for build-tool-specific operations.

This module provides generic authentication handling that allows both rebar3 and Elixir Hex (and future build tools) to share the common auth logic while customizing prompting, persistence, and configuration retrieval.

Callbacks

Callbacks are provided via the cli_auth_callbacks key in the config map. All callbacks below are required unless marked optional:

  #{
      %% Auth configuration for a specific repo
      get_auth_config => fun((RepoName :: binary()) ->
          #{api_key => binary(),
            auth_key => binary(),
            oauth_exchange => boolean(),
            oauth_exchange_url => binary()} | undefined),
 
      %% Global OAuth tokens - storage and retrieval
      get_oauth_tokens => fun(() -> {ok, #{access_token := binary(),
                                           refresh_token => binary(),
                                           expires_at := integer()}} | error),
      persist_oauth_tokens => fun((Scope :: global | binary(),
                                   AccessToken :: binary(),
                                   RefreshToken :: binary() | undefined,
                                   ExpiresAt :: integer()) -> ok),
 
      %% Invalidate the stored global OAuth token after the server refused to
      %% refresh it (optional). Lets the build tool drop the unusable
      %% token so concurrent and subsequent callers stop retrying the doomed
      %% refresh, and warn the user. Invoked at most once per resolution, while
      %% holding the token-refresh lock.
      clear_oauth_tokens => fun(() -> ok),
 
      %% Report the organizations the server says this session has to
      %% complete SSO or 2FA verification for (optional). Called
      %% after every token grant that carried a readable set, with the empty
      %% list when there are none, so the build tool always holds the current
      %% set. It is not told which of them the running command needs; deciding
      %% 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()),
 
      %% OAuth client configuration
      get_client_id => fun(() -> binary())
  }

Auth Resolution Order

For API calls:

  1. Per-repo api_key from config (with optional OAuth exchange for hex.pm)
  2. Parent repo api_key (for "hexpm:org" organizations)
  3. Global OAuth token (refreshed if expired)
  4. Device auth flow (for write operations only)

For repo calls:

  1. Per-repo auth_key with optional OAuth exchange (default true for hex.pm)
  2. Parent repo auth_key
  3. Global OAuth token
  4. Workload Identity token (for "hexpm:org" organizations)

OAuth Exchange

For hex.pm URLs, api_key and auth_key are exchanged for short-lived OAuth tokens via the client credentials grant. This behavior can be controlled per-repo via the oauth_exchange option in the repo config (defaults to true for hex.pm).

Auth Context

Internally, authentication resolution tracks context via auth_context():

  • has_refresh_token - Whether token refresh is possible on 401

Token Format

OAuth access tokens are automatically prefixed with <<"Bearer ">> when used as api_key or repo_key in the config.

Workload Identity

Workload Identity, sometimes also known as "Trusted Publishing", lets a 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.

Summary

Functions

Refreshes the stored global OAuth token now, whether or not it has expired.

Execute a function with API authentication.

Execute a function with API authentication.

Execute a function with repository authentication.

Execute a function with repository authentication.

Authenticates with a workload identity, exchanging the CI job's OIDC token for API auth scoped to Scope (for example <<"package:hexpm/my_package">>).

Types

auth_context/0

-type auth_context() :: #{has_refresh_token => boolean()}.

auth_error/0

-type auth_error() ::
          {auth_error, no_credentials} |
          {auth_error, auth_declined} |
          {auth_error, otp_cancelled} |
          {auth_error, otp_max_retries} |
          {auth_error, token_refresh_failed} |
          {auth_error, token_refresh_unavailable} |
          {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()}.

auth_prompt_reason/0

-type auth_prompt_reason() :: no_credentials | token_refresh_failed.

callbacks/0

-type callbacks() ::
          #{get_auth_config := fun((RepoName :: binary()) -> repo_auth_config() | undefined),
            get_oauth_tokens := fun(() -> {ok, oauth_tokens()} | error),
            persist_oauth_tokens :=
                fun((Scope :: global | binary(),
                     AccessToken :: binary(),
                     RefreshToken :: binary() | undefined,
                     ExpiresAt :: integer()) ->
                        ok),
            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())}.

oauth_tokens/0

-type oauth_tokens() :: #{access_token := binary(), refresh_token => binary(), expires_at := integer()}.

opts/0

-type opts() :: [{optional, boolean()} | {auth_inline, boolean()} | {oauth_open_browser, boolean()}].

permission/0

-type permission() :: read | write.

repo_auth_config/0

-type repo_auth_config() ::
          #{api_key => binary(),
            repo_key => binary(),
            auth_key => binary(),
            oauth_token => oauth_tokens()}.

workload_identity_error/0

-type workload_identity_error() ::
          hex_oidc:fetch_error() |
          {oidc_audience_failed, hex_api:response()} |
          {token_exchange_failed, hex_api:response()}.

Functions

refresh_tokens(Config)

-spec refresh_tokens(hex_core:config()) -> ok | {error, auth_error()}.

Refreshes the stored global OAuth token now, whether or not it has expired.

What a token carries can change without it expiring: authenticating a session for an organization's authentication requirements grants scopes the current access token was minted without. This is how a build tool picks those up rather than waiting out the access token.

with_api(Permission, BaseConfig, Fun)

-spec with_api(permission(), hex_core:config(), fun((hex_core:config()) -> Result)) ->
                  Result | {error, auth_error()}
                  when Result :: term().

Execute a function with API authentication.

Equivalent to with_api(Permission, Config, Fun, []).

See also: with_api/4.

with_api(Permission, BaseConfig, Fun, Opts)

-spec with_api(permission(), hex_core:config(), fun((hex_core:config()) -> Result), opts()) ->
                  Result | {error, auth_error()}
                  when Result :: term().

Execute a function with API authentication.

Resolves credentials in this order:

  1. Per-repo api_key from config (with optional OAuth exchange for hex.pm)
  2. Parent repo api_key (for "hexpm:org" organizations)
  3. Global OAuth token (refreshed if expired)
  4. Device auth flow (when should_authenticate callback returns true)

On 401 responses, handles OTP prompts and token refresh automatically.

The repository name is taken from the config (repo_name or repo_organization).

Callbacks are taken from the cli_auth_callbacks key in the config map.

Options:

  • optional - When true, if no credentials are found, executes the function without authentication first. If the server returns 401, triggers auth (respecting auth_inline). When false (default), missing credentials immediately triggers the should_authenticate callback.
  • auth_inline - When true (default), prompts the user via should_authenticate callback when authentication is needed. When false, returns {error, {auth_error, no_credentials}} instead of prompting.
  • oauth_open_browser - When true (default), automatically opens the browser during device auth flow. When false, only prints the URL for the user.

Example:

  hex_cli_auth:with_api(write, Config, fun(C) ->
      hex_api_release:publish(C, Tarball)
  end, [{optional, false}, {auth_inline, true}]).

with_repo(BaseConfig, Fun)

-spec with_repo(hex_core:config(), fun((hex_core:config()) -> Result)) -> Result | {error, auth_error()}
                   when Result :: term().

Execute a function with repository authentication.

Equivalent to with_repo(Config, Fun, []).

See also: with_repo/3.

with_repo(BaseConfig, Fun, Opts)

-spec with_repo(hex_core:config(), fun((hex_core:config()) -> Result), opts()) ->
                   Result | {error, auth_error()}
                   when Result :: term().

Execute a function with repository authentication.

Resolves credentials in this order:

  1. repo_key in config - passthrough
  2. repo_key from get_auth_config callback - passthrough
  3. auth_key from get_auth_config when trusted is true and oauth_exchange is true - exchange for OAuth token
  4. auth_key from get_auth_config when trusted is true - use directly
  5. Global OAuth token from get_oauth_tokens callback for Hex.pm repositories
  6. 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}}}.
  7. No auth when optional is true (with retry on 401)
  8. 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 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).

Callbacks are taken from the cli_auth_callbacks key in the config map.

Options:

  • optional - When true (default), proceeds without auth if none found; retries with auth on 401.
  • auth_inline - When true, prompts user via should_authenticate callback. Default is false.
  • oauth_open_browser - When true (default), automatically opens the browser during device auth flow. When false, only prints the URL for the user.

Example:

  hex_cli_auth:with_repo(Config, fun(C) ->
      hex_repo:get_tarball(C, <<"ecto">>, <<"3.0.0">>)
  end).

workload_identity_auth(Config, Scope)

-spec workload_identity_auth(hex_core:config(), Scope :: binary()) ->
                                {ok, binary()} | none | {error, workload_identity_error()}.

Authenticates with a workload identity, exchanging the CI job's OIDC token for API auth scoped to Scope (for example <<"package:hexpm/my_package">>).

Returns none when credentials already resolve through resolve_api_auth/2 (any credential the user configured takes precedence) or when no supported CI provider is detected, in which case the caller falls back to its ordinary auth resolution. Call this once per publish and reuse the resulting key for every request the publish makes; do not call it from inside with_api/3,4.