hex_cli_auth (hex_core v0.19.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 it expired and could
      %% not be refreshed (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),
 
      %% 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

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():

  • source - Where the credentials came from (env, config, or oauth)
  • 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.

Summary

Functions

Execute a function with API authentication.

Execute a function with API authentication.

Execute a function with repository authentication.

Execute a function with repository authentication.

Types

auth_context/0

-type auth_context() :: #{source => env | config | oauth, 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, device_auth_timeout} |
          {auth_error, device_auth_denied} |
          {auth_error, oauth_exchange_failed} |
          {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),
            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()}.

Functions

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
  6. No auth when optional is true (with retry on 401)
  7. Prompt via should_authenticate when auth_inline is true

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).