hex_cli_auth (hex_core v0.20.0)
View SourceAuthentication 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:
- Per-repo
api_keyfrom config (with optional OAuth exchange for hex.pm) - Parent repo
api_key(for "hexpm:org" organizations) - Global OAuth token (refreshed if expired)
- Device auth flow (for write operations only)
For repo calls:
- Per-repo
auth_keywith optional OAuth exchange (default true for hex.pm) - Parent repo
auth_key - Global OAuth token
- 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
-type auth_context() :: #{has_refresh_token => boolean()}.
-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()}.
-type auth_prompt_reason() :: no_credentials | token_refresh_failed.
-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())}.
-type permission() :: read | write.
-type repo_auth_config() :: #{api_key => binary(), repo_key => binary(), auth_key => binary(), oauth_token => oauth_tokens()}.
-type workload_identity_error() :: hex_oidc:fetch_error() | {oidc_audience_failed, hex_api:response()} | {token_exchange_failed, hex_api:response()}.
Functions
-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.
-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.
-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:
- Per-repo
api_keyfrom config (with optional OAuth exchange for hex.pm) - Parent repo
api_key(for "hexpm:org" organizations) - Global OAuth token (refreshed if expired)
- Device auth flow (when
should_authenticatecallback 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- Whentrue, if no credentials are found, executes the function without authentication first. If the server returns 401, triggers auth (respectingauth_inline). Whenfalse(default), missing credentials immediately triggers theshould_authenticatecallback.auth_inline- Whentrue(default), prompts the user viashould_authenticatecallback when authentication is needed. Whenfalse, returns{error, {auth_error, no_credentials}}instead of prompting.oauth_open_browser- Whentrue(default), automatically opens the browser during device auth flow. Whenfalse, 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}]).
-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.
-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:
repo_keyin config - passthroughrepo_keyfromget_auth_configcallback - passthroughauth_keyfromget_auth_configwhentrustedis true andoauth_exchangeis true - exchange for OAuth tokenauth_keyfromget_auth_configwhentrustedis true - use directly- Global OAuth token from
get_oauth_tokenscallback 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
optionalis true, and a 401 or 403 is returned as{error, {auth_error, {workload_identity_failed, Reason}}}. - No auth when
optionalis true (with retry on 401) - Prompt via
should_authenticatewhenauth_inlineis 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- Whentrue(default), proceeds without auth if none found; retries with auth on 401.auth_inline- Whentrue, prompts user viashould_authenticatecallback. Default isfalse.oauth_open_browser- Whentrue(default), automatically opens the browser during device auth flow. Whenfalse, 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).
-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.