%% @doc %% 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. %%
  3. Parent repo `api_key' (for "hexpm:org" organizations)
  4. %%
  5. Global OAuth token (refreshed if expired)
  6. %%
  7. Device auth flow (for write operations only)
  8. %%
%% %% For repo calls: %%
    %%
  1. Per-repo `auth_key' with optional OAuth exchange (default true for hex.pm)
  2. %%
  3. Parent repo `auth_key'
  4. %%
  5. Global OAuth token
  6. %%
%% %% == 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()': %% %% %% == Token Format == %% %% OAuth access tokens are automatically prefixed with `<<"Bearer ">>' when used %% as `api_key' or `repo_key' in the config. -module(hex_cli_auth). -export([ with_api/3, with_api/4, with_repo/2, with_repo/3, resolve_api_auth/2, resolve_repo_auth/1 ]). -export_type([ callbacks/0, permission/0, auth_error/0, auth_context/0, repo_auth_config/0, auth_prompt_reason/0, opts/0 ]). %% 5 minute buffer before expiry -define(EXPIRY_BUFFER_SECONDS, 300). %% Maximum OTP retry attempts -define(MAX_OTP_RETRIES, 3). -type permission() :: read | write. -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()) }. -type auth_prompt_reason() :: no_credentials | token_refresh_failed. -type repo_auth_config() :: #{ api_key => binary(), repo_key => binary(), auth_key => binary(), oauth_token => oauth_tokens() }. -type oauth_tokens() :: #{ access_token := binary(), refresh_token => binary(), expires_at := integer() }. -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()}. -type auth_context() :: #{ source => env | config | oauth, has_refresh_token => boolean() }. -type opts() :: [ {optional, boolean()} | {auth_inline, boolean()} | {oauth_open_browser, boolean()} ]. %%==================================================================== %% API functions %%==================================================================== %% @doc %% Execute a function with API authentication. %% %% Equivalent to `with_api(Permission, Config, Fun, [])'. %% %% @see with_api/4 -spec with_api(permission(), hex_core:config(), fun((hex_core:config()) -> Result)) -> Result | {error, auth_error()} when Result :: term(). with_api(Permission, BaseConfig, Fun) -> with_api(Permission, BaseConfig, Fun, []). %% @doc %% 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. %%
  3. Parent repo `api_key' (for "hexpm:org" organizations)
  4. %%
  5. Global OAuth token (refreshed if expired)
  6. %%
  7. Device auth flow (when `should_authenticate' callback returns true)
  8. %%
%% %% 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: %% %% %% Example: %% ``` %% hex_cli_auth:with_api(write, Config, fun(C) -> %% hex_api_release:publish(C, Tarball) %% end, [{optional, false}, {auth_inline, true}]). %% ''' -spec with_api( permission(), hex_core:config(), fun((hex_core:config()) -> Result), opts() ) -> Result | {error, auth_error()} when Result :: term(). with_api(Permission, BaseConfig, Fun, Opts) -> Optional = proplists:get_value(optional, Opts, false), AuthInline = proplists:get_value(auth_inline, Opts, true), case resolve_api_auth(Permission, BaseConfig) of {ok, ApiKey, AuthContext} -> Config = BaseConfig#{api_key => ApiKey}, execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); {error, no_auth} when Optional =:= true -> %% Auth is optional, try without credentials first execute_optional_with_retry(BaseConfig, Fun, Opts); {error, no_auth} when AuthInline =:= true -> %% No auth found, ask user if they want to authenticate maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); {error, no_auth} -> %% auth_inline is false, just return error {error, {auth_error, no_credentials}}; {error, {auth_error, token_refresh_failed}} when Optional =:= true -> %% Token refresh failed but auth is optional, fall back to no credentials execute_optional_with_retry(BaseConfig, Fun, Opts); {error, _} = Error -> Error end. %% @doc %% Execute a function with repository authentication. %% %% Equivalent to `with_repo(Config, Fun, [])'. %% %% @see with_repo/3 -spec with_repo(hex_core:config(), fun((hex_core:config()) -> Result)) -> Result | {error, auth_error()} when Result :: term(). with_repo(BaseConfig, Fun) -> with_repo(BaseConfig, Fun, []). %% @doc %% Execute a function with repository authentication. %% %% Resolves credentials in this order: %%
    %%
  1. `repo_key' in config - passthrough
  2. %%
  3. `repo_key' from `get_auth_config' callback - passthrough
  4. %%
  5. `auth_key' from `get_auth_config' when `trusted' is true and `oauth_exchange' is true - exchange for OAuth token
  6. %%
  7. `auth_key' from `get_auth_config' when `trusted' is true - use directly
  8. %%
  9. Global OAuth token from `get_oauth_tokens' callback
  10. %%
  11. No auth when `optional' is true (with retry on 401)
  12. %%
  13. Prompt via `should_authenticate' when `auth_inline' is true
  14. %%
%% %% 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: %% %% %% Example: %% ``` %% hex_cli_auth:with_repo(Config, fun(C) -> %% hex_repo:get_tarball(C, <<"ecto">>, <<"3.0.0">>) %% end). %% ''' -spec with_repo(hex_core:config(), fun((hex_core:config()) -> Result), opts()) -> Result | {error, auth_error()} when Result :: term(). with_repo(BaseConfig, Fun, Opts) -> Optional = proplists:get_value(optional, Opts, true), AuthInline = proplists:get_value(auth_inline, Opts, false), case resolve_repo_auth(BaseConfig) of {ok, RepoKey, _AuthContext} when is_binary(RepoKey) -> Config = BaseConfig#{repo_key => RepoKey}, Fun(Config); no_auth when Optional =:= true -> %% Auth is optional, try without credentials first execute_optional_with_retry(BaseConfig, Fun, Opts); no_auth when AuthInline =:= true -> %% No auth found, ask user if they want to authenticate maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); no_auth -> %% auth_inline is false, return error {error, {auth_error, no_credentials}}; {error, {auth_error, token_refresh_failed}} when Optional =:= true -> %% Token refresh failed but auth is optional, fall back to no credentials execute_optional_with_retry(BaseConfig, Fun, Opts); {error, _} = Error -> Error end. %% @private %% Extract repository name from config. -spec repo_name(hex_core:config()) -> binary(). repo_name(#{repo_name := Name, repo_organization := Org}) when is_binary(Name) and is_binary(Org) -> <>; repo_name(#{repo_name := Name}) when is_binary(Name) -> Name; repo_name(_) -> <<"hexpm">>. %% @private %% Ask user if they want to authenticate, and if yes, initiate device auth. %% %% Serialized with a global lock so concurrent callers don't each trigger their %% own device auth flow. The first caller to acquire the lock runs device auth %% and persists the resulting token; subsequent callers re-check for an existing %% (now-valid) token inside the lock and reuse it instead of re-authenticating. maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) -> global:trans( {{?MODULE, device_auth}, self()}, fun() -> do_maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) end, [node()], infinity ). %% @private %% Another caller may have authenticated while we waited for the lock. Re-resolve %% and, if we get a token that differs from the one we arrived with (none when %% credentials were missing; the rejected one on token_refresh_failed), reuse it %% instead of prompting again. Otherwise proceed to prompt + device auth. do_maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) -> CurrentApiKey = maps:get(api_key, BaseConfig, undefined), case resolve_api_auth(write, BaseConfig) of {ok, ApiKey, AuthContext} when ApiKey =/= CurrentApiKey -> Config = BaseConfig#{api_key => ApiKey}, execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); _ -> prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) end. %% @private prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) -> case call_callback(BaseConfig, should_authenticate, [Reason]) of true -> case device_auth(BaseConfig, <<"api repositories">>, Opts) of {ok, #{access_token := Token}} -> BearerToken = <<"Bearer ", Token/binary>>, Config = BaseConfig#{api_key => BearerToken}, AuthContext = #{source => oauth, has_refresh_token => true}, execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); {error, _} = Error -> Error end; false -> {error, {auth_error, auth_declined}} end. %% @private %% Execute function without auth, but retry with auth if we get a 401. execute_optional_with_retry(BaseConfig, Fun, Opts) -> AuthInline = proplists:get_value(auth_inline, Opts, true), case Fun(BaseConfig) of {ok, {401, _Headers, _Body}} when AuthInline =:= true -> %% Got 401, need auth - ask user if they want to authenticate maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); {ok, {401, _Headers, _Body}} -> %% Got 401 but auth_inline is false, return error {error, {auth_error, no_credentials}}; Other -> Other end. %%==================================================================== %% Internal functions - Device Auth %%==================================================================== %% @private %% Initiate OAuth device authorization flow. %% Prompts user, optionally opens the browser for user authentication, %% polls for token completion, and persists tokens via callback on success. -spec device_auth(hex_core:config(), binary(), opts()) -> {ok, oauth_tokens()} | {error, auth_error()}. device_auth(Config, Scope, Opts) -> ClientId = call_callback(Config, get_client_id, []), OpenBrowser = proplists:get_value(oauth_open_browser, Opts, true), PromptUser = fun(VerificationUri, UserCode) -> io:format("Open ~ts in your browser and enter code: ~ts~n", [VerificationUri, UserCode]) end, FlowOpts = [{open_browser, OpenBrowser}], case hex_api_oauth:device_auth_flow(Config, ClientId, Scope, PromptUser, FlowOpts) of {ok, #{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt}} -> ok = call_callback(Config, persist_oauth_tokens, [ global, AccessToken, RefreshToken, ExpiresAt ]), {ok, #{ access_token => AccessToken, refresh_token => RefreshToken, expires_at => ExpiresAt }}; {error, timeout} -> {error, {auth_error, device_auth_timeout}}; {error, {access_denied, _Status, _Body}} -> {error, {auth_error, device_auth_denied}}; {error, {device_auth_failed, _Status, _Body} = Reason} -> {error, {auth_error, Reason}}; {error, {poll_failed, _Status, _Body} = Reason} -> {error, {auth_error, Reason}}; {error, Reason} -> {error, {auth_error, Reason}} end. %% @private %% Check if a token is expired (within 5 minute buffer). -spec is_token_expired(integer()) -> boolean(). is_token_expired(ExpiresAt) -> Now = erlang:system_time(second), ExpiresAt - Now < ?EXPIRY_BUFFER_SECONDS. %%==================================================================== %% Internal functions - Auth Resolution %%==================================================================== %% @private -spec resolve_api_auth(permission(), hex_core:config()) -> {ok, binary(), auth_context()} | {error, no_auth} | {error, auth_error()}. resolve_api_auth(_Permission, #{api_key := ApiKey}) when is_binary(ApiKey) -> %% api_key already in config, pass through directly {ok, ApiKey, #{source => config, has_refresh_token => false}}; resolve_api_auth(_Permission, Config) -> RepoName = repo_name(Config), %% 1. Check per-repo api_key case call_callback(Config, get_auth_config, [RepoName]) of #{api_key := ApiKey} when is_binary(ApiKey) -> {ok, ApiKey, #{source => config, has_refresh_token => false}}; _ -> %% 2. Check parent repo (for "hexpm:org" organizations) case get_parent_repo_key(Config, RepoName, api_key) of {ok, ApiKey} -> {ok, ApiKey, #{source => config, has_refresh_token => false}}; error -> %% 3. Try global OAuth token resolve_oauth_token_with_context(Config) end end. %% @private %% Resolve repo auth credentials in this order: %% 0. repo_key in config => passthrough %% 1. repo_key from get_auth_config => passthrough %% 2. trusted + auth_key + oauth_exchange => exchange for OAuth token %% 3. trusted + auth_key => use directly %% 4. trusted + global OAuth tokens => use those %% 5. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) -spec resolve_repo_auth(hex_core:config()) -> {ok, binary(), auth_context()} | no_auth | {error, auth_error()}. resolve_repo_auth(#{repo_key := RepoKey}) when is_binary(RepoKey) -> %% repo_key already in config, pass through directly {ok, RepoKey, #{source => config, has_refresh_token => false}}; resolve_repo_auth(Config) -> RepoName = repo_name(Config), global:trans( {{?MODULE, repo, RepoName}, self()}, fun() -> do_resolve_repo_auth(RepoName, RepoName, Config) end, [node()], infinity ). do_resolve_repo_auth(RepoName, LookupRepo, Config) -> Trusted = maps:get(trusted, Config, false), OAuthExchange = maps:get(oauth_exchange, Config, false), case call_callback(Config, get_auth_config, [LookupRepo]) of #{repo_key := RepoKey} when is_binary(RepoKey) -> %% 1. repo_key from get_auth_config => passthrough {ok, RepoKey, #{source => config, has_refresh_token => false}}; #{oauth_token := OAuthToken, auth_key := AuthKey} when is_binary(AuthKey) and OAuthExchange, Trusted -> %% 2. trusted + oauth_token + auth_key + oauth_exchange => use/refresh existing token resolve_repo_oauth_token(RepoName, Config, AuthKey, OAuthToken); #{auth_key := AuthKey} when is_binary(AuthKey) and OAuthExchange, Trusted -> %% 3. trusted + auth_key + oauth_exchange => exchange for new OAuth token exchange_for_oauth_token(RepoName, Config, AuthKey, <<"repositories">>); #{auth_key := AuthKey} when is_binary(AuthKey), Trusted -> %% 4. trusted + auth_key => use directly {ok, AuthKey, #{source => config, has_refresh_token => false}}; _ when Trusted -> %% 5. Check parent repo (for "hexpm:org" organizations) case binary:split(LookupRepo, <<":">>) of [ParentName, _OrgName] -> do_resolve_repo_auth(RepoName, ParentName, Config); _ -> %% 6. trusted + global OAuth tokens => use those resolve_global_oauth_for_repo(Config) end; _ -> %% 7. Not trusted, no auth no_auth end. %% @private resolve_global_oauth_for_repo(Config) -> case resolve_oauth_token_with_context(Config) of {ok, Token, AuthContext} -> {ok, Token, AuthContext}; {error, no_auth} -> no_auth; {error, _} = Error -> Error end. %% @private %% Resolve repo OAuth token: use if valid, re-exchange if expiring. resolve_repo_oauth_token(RepoName, Config, AuthKey, #{ access_token := AccessToken, expires_at := ExpiresAt }) -> case is_token_expired(ExpiresAt) of false -> %% Token is still valid, use it BearerToken = <<"Bearer ", AccessToken/binary>>, {ok, BearerToken, #{source => oauth, has_refresh_token => false}}; true -> %% Token expired, do a new exchange exchange_for_oauth_token(RepoName, Config, AuthKey, <<"repositories">>) end. %% @private %% Exchange api_key/auth_key for OAuth token via client credentials grant. %% 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, case hex_api_oauth:client_credentials_token(ExchangeConfig, ClientId, AuthKey, Scope) of {ok, {200, _, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} -> ExpiresAt = erlang:system_time(second) + ExpiresIn, ok = call_callback(Config, persist_oauth_tokens, [ RepoName, AccessToken, undefined, ExpiresAt ]), BearerToken = <<"Bearer ", AccessToken/binary>>, {ok, BearerToken, #{source => oauth, has_refresh_token => false}}; {ok, {_Status, _, _Body}} -> {error, {auth_error, oauth_exchange_failed}}; {error, _} -> {error, {auth_error, oauth_exchange_failed}} end. %% @private get_parent_repo_key(Config, RepoName, KeyType) -> case binary:split(RepoName, <<":">>) of [ParentName, _OrgName] -> case call_callback(Config, get_auth_config, [ParentName]) of #{KeyType := Key} when is_binary(Key) -> {ok, Key}; _ -> error end; _ -> error end. %% @private %% Resolve OAuth token with global lock to prevent concurrent refresh attempts. resolve_oauth_token_with_context(Config) -> global:trans( {{?MODULE, token_refresh}, self()}, fun() -> do_resolve_oauth_token_with_context(Config) end, [node()], infinity ). %% @private do_resolve_oauth_token_with_context(Config) -> case call_callback(Config, get_oauth_tokens, []) of {ok, #{access_token := AccessToken, expires_at := ExpiresAt} = Tokens} -> HasRefreshToken = maps:is_key(refresh_token, Tokens) andalso is_binary(maps:get(refresh_token, Tokens)), case is_token_expired(ExpiresAt) of true -> refresh_or_clear(Config, Tokens); false -> BearerToken = <<"Bearer ", AccessToken/binary>>, {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}} end; error -> {error, no_auth} end. %% @private %% Refresh an expired global token; if the refresh fails, invalidate the stored %% token via the optional clear_oauth_tokens callback. This runs inside the %% token_refresh lock, so the unusable token is dropped exactly once and the %% callers serialized behind the lock re-read it as absent instead of each %% retrying the doomed refresh against the server. refresh_or_clear(Config, Tokens) -> case maybe_refresh_token_with_context(Config, Tokens) of {ok, _Bearer, _Ctx} = Ok -> Ok; {error, _} = Error -> maybe_call_callback(Config, clear_oauth_tokens, []), Error end. %% @private maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when is_binary(RefreshToken) -> ClientId = call_callback(Config, get_client_id, []), case hex_api_oauth:refresh_token(Config, ClientId, RefreshToken) of {ok, {200, _, TokenResponse}} when is_map(TokenResponse) -> #{ <<"access_token">> := NewAccessToken, <<"expires_in">> := ExpiresIn } = TokenResponse, NewRefreshToken = maps:get(<<"refresh_token">>, TokenResponse, RefreshToken), ExpiresAt = erlang:system_time(second) + ExpiresIn, ok = call_callback(Config, persist_oauth_tokens, [ global, NewAccessToken, NewRefreshToken, ExpiresAt ]), BearerToken = <<"Bearer ", NewAccessToken/binary>>, HasRefreshToken = is_binary(NewRefreshToken), {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}}; {ok, {_Status, _, _Body}} -> {error, {auth_error, token_refresh_failed}}; {error, _Reason} -> {error, {auth_error, token_refresh_failed}} end; maybe_refresh_token_with_context(_Config, _Tokens) -> {error, {auth_error, token_refresh_failed}}. %%==================================================================== %% Internal functions - Retry Logic %%==================================================================== %% @private execute_with_retry(Config, Fun, AuthContext, OtpRetries, LastOtpError, Opts) -> case Fun(Config) of {error, otp_required} -> handle_otp_retry( Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts ); {error, invalid_totp} -> handle_otp_retry( Config, Fun, AuthContext, OtpRetries, <<"Invalid OTP code. Please try again:">>, Opts ); {ok, {401, Headers, _Body}} = Response -> case detect_auth_error(Headers) of otp_required -> handle_otp_retry( Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts ); invalid_totp -> Msg = case LastOtpError of invalid_totp -> <<"Invalid OTP code. Please try again:">>; _ -> <<"Enter OTP code:">> end, handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Msg, Opts); token_expired -> handle_token_refresh_retry(Config, Fun, AuthContext, Opts); none -> Response end; Other -> Other end. %% @private handle_otp_retry(_Config, _Fun, _AuthContext, OtpRetries, _Message, _Opts) when OtpRetries >= ?MAX_OTP_RETRIES -> {error, {auth_error, otp_max_retries}}; handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Message, Opts) -> case call_callback(Config, prompt_otp, [Message]) of {ok, OtpCode} -> NewConfig = Config#{api_otp => OtpCode}, execute_with_retry( NewConfig, Fun, AuthContext, OtpRetries + 1, invalid_totp, Opts ); cancelled -> {error, {auth_error, otp_cancelled}} end. %% @private handle_token_refresh_retry(Config, Fun, AuthContext, Opts) -> %% Only attempt refresh if we have a refresh token case maps:get(has_refresh_token, AuthContext, false) of true -> case resolve_oauth_token_with_context(Config) of {ok, NewBearerToken, NewAuthContext} -> NewConfig = Config#{api_key => NewBearerToken}, execute_with_retry( NewConfig, Fun, NewAuthContext, 0, undefined, Opts ); {error, _} -> maybe_reauthenticate(Config, Fun, Opts) end; false -> maybe_reauthenticate(Config, Fun, Opts) end. %% @private %% After token refresh failure, prompt the user to re-authenticate via device auth %% (only when auth_inline is true). Mirrors Hex.OAuth.reauthenticate/1. maybe_reauthenticate(Config, Fun, Opts) -> AuthInline = proplists:get_value(auth_inline, Opts, true), case AuthInline of true -> maybe_authenticate_and_retry(Config, Fun, token_refresh_failed, Opts); false -> {error, {auth_error, token_refresh_failed}} end. %% @private -spec detect_auth_error(hex_http:headers()) -> otp_required | invalid_totp | token_expired | none. detect_auth_error(Headers) -> case maps:get(<<"www-authenticate">>, Headers, undefined) of undefined -> none; Value -> parse_www_authenticate(Value) end. %% @private parse_www_authenticate(Value) when is_binary(Value) -> case Value of <<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> -> otp_required; <<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> -> invalid_totp; <<"Bearer realm=\"hex\", error=\"token_expired\"", _/binary>> -> token_expired; _ -> none end. %%==================================================================== %% Internal functions - Utilities %%==================================================================== %% @private call_callback(Config, Name, Args) -> #{cli_auth_callbacks := Callbacks} = Config, Fun = maps:get(Name, Callbacks), erlang:apply(Fun, Args). %% @private %% Like call_callback/3 but for optional callbacks: returns ok without doing %% anything when the callback is not provided. maybe_call_callback(Config, Name, Args) -> #{cli_auth_callbacks := Callbacks} = Config, case maps:find(Name, Callbacks) of {ok, Fun} -> erlang:apply(Fun, Args); error -> ok end.