diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl
index e1e0e6c..b1f3526 100644
--- a/src/hex_api_oauth.erl
+++ b/src/hex_api_oauth.erl
@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
+ sso_authorization/2,
+ sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
- client_credentials_token/5
+ client_credentials_token/5,
+ win_cmd_args/1
]).
-export_type([oauth_tokens/0, device_auth_error/0]).
-type oauth_tokens() :: #{
access_token := binary(),
- refresh_token => binary() | undefined,
- expires_at := integer()
+ refresh_token => binary(),
+ expires_at := integer(),
+ %% Organizations the session must authenticate against their identity
+ %% provider for. Their scopes are not in this token and re-requesting them
+ %% will not help; see sso_authorization/2.
+ sso_reauth_required => [binary()]
}.
-type device_auth_error() ::
@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
- {ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
- #{
- <<"device_code">> := DeviceCode,
- <<"user_code">> := UserCode,
- <<"verification_uri_complete">> := VerificationUri,
- <<"expires_in">> := ExpiresIn,
- <<"interval">> := IntervalSeconds
- } = DeviceResponse,
- ok = PromptUser(VerificationUri, UserCode),
- OpenBrowser = proplists:get_value(open_browser, Opts, false),
- case OpenBrowser of
- true -> open_browser(VerificationUri);
- false -> ok
- end,
- ExpiresAt = erlang:system_time(second) + ExpiresIn,
- poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
+ {ok, {200, _, DeviceResponse}} ->
+ case device_authorization_fields(DeviceResponse) of
+ {ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
+ ok = PromptUser(VerificationUri, UserCode),
+ OpenBrowser = proplists:get_value(open_browser, Opts, false),
+ case OpenBrowser of
+ true -> open_browser(VerificationUri);
+ false -> ok
+ end,
+ ExpiresAt = erlang:system_time(second) + ExpiresIn,
+ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
+ error ->
+ {error, {device_auth_failed, 200, DeviceResponse}}
+ end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.
+%% @private
+%% The fields the flow goes on to use, in the types it uses them as: the
+%% interval is slept on and the expiry is added to a timestamp.
+device_authorization_fields(#{
+ <<"device_code">> := DeviceCode,
+ <<"user_code">> := UserCode,
+ <<"verification_uri_complete">> := VerificationUri,
+ <<"expires_in">> := ExpiresIn,
+ <<"interval">> := Interval
+}) when
+ is_binary(DeviceCode),
+ is_binary(UserCode),
+ is_binary(VerificationUri),
+ is_integer(ExpiresIn),
+ is_integer(Interval),
+ Interval >= 0
+->
+ {ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
+device_authorization_fields(_DeviceResponse) ->
+ error.
+
%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
- {ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
- #{
- <<"access_token">> := AccessToken,
- <<"expires_in">> := ExpiresIn
- } = TokenResponse,
- RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
- TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
- {ok, #{
- access_token => AccessToken,
- refresh_token => RefreshToken,
- expires_at => TokenExpiresAt
- }};
+ {ok, {200, _, TokenResponse}} ->
+ case token_response_fields(TokenResponse) of
+ {ok, AccessToken, ExpiresIn} ->
+ Tokens = #{
+ access_token => AccessToken,
+ expires_at => erlang:system_time(second) + ExpiresIn
+ },
+ {ok,
+ put_sso_reauth_required(
+ put_refresh_token(Tokens, TokenResponse), TokenResponse
+ )};
+ error ->
+ {error, {poll_failed, 200, TokenResponse}}
+ end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
- {error, Reason} ->
- {error, Reason}
+ {error, _Reason} ->
+ %% A request that did not get through says nothing about the
+ %% authorization, which the user may be minutes into. The
+ %% device code lives on the server until it expires, so keep
+ %% polling until then.
+ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.
@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).
+%% @doc
+%% Requests a URL for authenticating the current session against organizations
+%% that require single sign-on.
+%%
+%% The session the access token belongs to is the one being authorized: its
+%% owner opens the URL in a browser, completes SSO, and the next token refresh
+%% carries the scopes again. The URL is single-use and short-lived.
+%%
+%% Examples:
+%%
+%% ```
+%% 1> Config = hex_core:default_config().
+%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
+%% {ok, {201, _, #{
+%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
+%% <<"expires_in">> => 600
+%% }}}
+%% '''
+%% @end
+-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
+sso_authorization(Config, Organizations) ->
+ Path = <<"oauth/sso_authorization">>,
+ hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).
+
%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).
+%% @doc
+%% Organizations a token response says the session has to authenticate against
+%% their identity provider for.
+%%
+%% Returns `{ok, []}' when the response does not carry the field, which is what
+%% servers that predate it send and means nothing is lapsed. Returns `error'
+%% when the field is there in a shape that cannot be read, which says nothing
+%% about what has lapsed and must not be taken for the empty set.
+%% @end
+-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
+sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
+ case lists:all(fun is_binary/1, Organizations) of
+ true -> {ok, Organizations};
+ false -> error
+ end;
+sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
+ error;
+sso_reauth_required(_TokenResponse) ->
+ {ok, []}.
+
%%====================================================================
%% Internal functions
%%====================================================================
%% @private
-%% Open a URL in the default browser.
-%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
--spec open_browser(binary()) -> ok | {error, browser_not_found}.
+%% Opens a URL in the default browser using the platform's opener: `open' on
+%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
+%% `{error, browser_not_found}' when none of them exists, which is the ordinary
+%% case on a headless machine, and `{error, invalid_url}' for anything that is
+%% not an http(s) URL.
+-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
- ok = ensure_valid_http_url(Url),
- UrlStr = binary_to_list(Url),
+ case valid_http_url(Url) of
+ true ->
+ spawn_browser(binary_to_list(Url));
+ false ->
+ {error, invalid_url}
+ end.
+
+%% @private
+spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
- {"open", [UrlStr]};
+ {"open", [Url]};
{unix, _} ->
- {"xdg-open", [UrlStr]};
+ {"xdg-open", [Url]};
{win32, _} ->
- {"cmd", ["/c", "start", "", UrlStr]}
+ {"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.
%% @private
-%% Validates that a URL uses http:// or https:// scheme.
--spec ensure_valid_http_url(binary()) -> ok.
-ensure_valid_http_url(Url) when is_binary(Url) ->
+%% `start' takes its first quoted argument as the window title, so the empty
+%% string keeps the URL in the position `start' opens.
+-spec win_cmd_args(string()) -> [string()].
+win_cmd_args(Url) ->
+ ["/c", "start", "", escape_win_cmd(Url)].
+
+%% @private
+%% cmd.exe parses the command line before `start' sees it, and erts only quotes
+%% an argument that contains whitespace, so every character cmd acts on is
+%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
+%% scans for separators.
+escape_win_cmd(Url) ->
+ lists:flatmap(fun escape_win_cmd_character/1, Url).
+
+%% @private
+escape_win_cmd_character(Character) when
+ Character =:= $^;
+ Character =:= $&;
+ Character =:= $|;
+ Character =:= $<;
+ Character =:= $>;
+ Character =:= $(;
+ Character =:= $);
+ Character =:= $";
+ Character =:= $%
+->
+ [$^, Character];
+escape_win_cmd_character(Character) ->
+ [Character].
+
+%% @private
+%% Whether a URL uses the http:// or https:// scheme.
+-spec valid_http_url(binary()) -> boolean().
+valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
- #{scheme := <<"https">>} -> ok;
- #{scheme := <<"http">>} -> ok;
- _ -> throw({invalid_url, Url})
+ #{scheme := <<"https">>} -> true;
+ #{scheme := <<"http">>} -> true;
+ _ -> false
+ end.
+
+%% @private
+%% The access token and its lifetime, in the types the caller uses them as.
+token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
+ is_binary(AccessToken), is_integer(ExpiresIn)
+->
+ {ok, AccessToken, ExpiresIn};
+token_response_fields(_TokenResponse) ->
+ error.
+
+%% @private
+%% A response without a refresh token carries no key at all rather than a
+%% placeholder, so what a build tool stores is only ever a real token.
+put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
+ Tokens#{refresh_token => RefreshToken};
+put_refresh_token(Tokens, _TokenResponse) ->
+ Tokens.
+
+%% @private
+%% A response whose sso_reauth_required cannot be read carries no key, so the
+%% caller is not handed the empty set as if the server had sent it.
+put_sso_reauth_required(Tokens, TokenResponse) ->
+ case sso_reauth_required(TokenResponse) of
+ {ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
+ error -> Tokens
end.
%% @private
diff --git a/src/hex_cli_auth.erl b/src/hex_cli_auth.erl
index a17dce3..12b0631 100644
--- a/src/hex_cli_auth.erl
+++ b/src/hex_cli_auth.erl
@@ -28,13 +28,21 @@
%% 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
+%% %% 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
+%% %% authenticate against their identity provider 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.
+%% sso_reauth => fun(([binary()]) -> ok),
+%%
%% %% User interaction
%% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
%% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()),
@@ -71,7 +79,6 @@
%%
%% 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
%%
%%
@@ -87,7 +94,9 @@
with_repo/2,
with_repo/3,
resolve_api_auth/2,
- resolve_repo_auth/1
+ resolve_repo_auth/1,
+ refresh_tokens/1,
+ is_token_expired/1
]).
-export_type([
@@ -106,6 +115,16 @@
%% Maximum OTP retry attempts
-define(MAX_OTP_RETRIES, 3).
+%% Maximum times a 401 that says the token expired is answered by renewing the
+%% credential and running the request again.
+-define(MAX_TOKEN_RETRIES, 1).
+
+%% Both ways a refresh can leave us without a usable token: the server refused
+%% the refresh token, or the refresh got no usable answer.
+-define(IS_REFRESH_FAILURE(Reason),
+ (Reason =:= token_refresh_failed orelse Reason =:= token_refresh_unavailable)
+).
+
-type permission() :: read | write.
-type callbacks() :: #{
@@ -120,6 +139,7 @@
) -> ok
),
clear_oauth_tokens => fun(() -> ok),
+ sso_reauth => fun((Organizations :: [binary()]) -> ok),
prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()),
get_client_id := fun(() -> binary())
@@ -147,17 +167,27 @@
| {auth_error, auth_declined}
| {auth_error, otp_cancelled}
| {auth_error, otp_max_retries}
+ %% The server refused the refresh token: 400 or 401
| {auth_error, token_refresh_failed}
+ %% The refresh got no usable answer: DNS, connect, timeout, TLS, a 429 or a
+ %% 5xx, or a 200 whose body is not a token response
+ | {auth_error, token_refresh_unavailable}
| {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()
}.
+%% How much of each retry budget a request has already spent.
+-type retries() :: #{
+ otp := non_neg_integer(),
+ otp_error := invalid_totp | undefined,
+ token := non_neg_integer()
+}.
+
-type opts() :: [
{optional, boolean()}
| {auth_inline, boolean()}
@@ -232,19 +262,34 @@ with_api(Permission, BaseConfig, Fun, Opts) ->
case resolve_api_auth(Permission, BaseConfig) of
{ok, ApiKey, AuthContext} ->
Config = BaseConfig#{api_key => ApiKey},
- execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts);
+ execute_with_retry(Config, Fun, AuthContext, initial_retries(), Opts);
{error, no_auth} when Optional =:= true ->
%% Auth is optional, try without credentials first
- execute_optional_with_retry(BaseConfig, Fun, Opts);
+ execute_optional_with_retry(api, BaseConfig, Fun, AuthInline, 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);
+ maybe_authenticate_and_retry(
+ api, BaseConfig, Fun, no_credentials, initial_retries(), 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 ->
+ {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(BaseConfig, Fun, Opts);
+ execute_optional_with_retry(api, BaseConfig, Fun, AuthInline, Opts);
+ {error, {auth_error, token_refresh_failed}} when AuthInline =:= true ->
+ %% The server refused the refresh token, which leaves us with no
+ %% usable token, same as having none, so it gets the same offer to
+ %% authenticate. Without this the caller that asked to be prompted up
+ %% front is told to run mix hex.user auth instead of being asked.
+ %%
+ %% Not token_refresh_unavailable: there the refresh got no answer at
+ %% all, so the token may well still be good and the network is what
+ %% is wrong. Offering a device flow that needs the same network is no
+ %% help.
+ maybe_authenticate_and_retry(
+ api, BaseConfig, Fun, token_refresh_failed, initial_retries(), Opts
+ );
{error, _} = Error ->
Error
end.
@@ -276,6 +321,10 @@ with_repo(BaseConfig, Fun) ->
%% 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.
+%%
%% 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.
@@ -303,20 +352,21 @@ with_repo(BaseConfig, Fun, Opts) ->
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);
+ execute_repo_with_retry(BaseConfig, Fun, RepoKey);
no_auth when Optional =:= true ->
%% Auth is optional, try without credentials first
- execute_optional_with_retry(BaseConfig, Fun, Opts);
+ execute_optional_with_retry(repo, BaseConfig, Fun, AuthInline, 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);
+ maybe_authenticate_and_retry(
+ repo, BaseConfig, Fun, no_credentials, initial_retries(), Opts
+ );
no_auth ->
%% auth_inline is false, return error
{error, {auth_error, no_credentials}};
- {error, {auth_error, token_refresh_failed}} when Optional =:= true ->
+ {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(BaseConfig, Fun, Opts);
+ execute_optional_with_retry(repo, BaseConfig, Fun, AuthInline, Opts);
{error, _} = Error ->
Error
end.
@@ -331,47 +381,65 @@ 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(
+%% Ask user if they want to authenticate, and if yes, initiate device auth, then
+%% run the request with what that produced.
+%%
+%% Kind says which credential the retried request needs: `api' takes the token
+%% as api_key, `repo' resolves repository auth and takes it as repo_key.
+%%
+%% Acquiring the credential is 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. The request runs outside the lock.
+%% A 401 brings it back here, and global:trans/4 on a lock this process already
+%% holds does not take a second reference: the inner transaction's exit deletes
+%% the lock entry while the outer one is still running.
+maybe_authenticate_and_retry(Kind, BaseConfig, Fun, Reason, Retries, Opts) ->
+ Credential = global:trans(
{{?MODULE, device_auth}, self()},
fun() ->
- do_maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts)
+ acquire_credential(Kind, BaseConfig, Reason, Opts)
end,
[node()],
infinity
- ).
+ ),
+ case Credential of
+ {ok, Key, AuthContext} ->
+ execute_authenticated(Kind, BaseConfig, Fun, Key, AuthContext, Retries, Opts);
+ {error, _} = Error ->
+ Error
+ end.
%% @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) ->
+acquire_credential(api, BaseConfig, 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);
+ {ok, ApiKey, AuthContext};
+ _ ->
+ prompt_and_device_auth(api, BaseConfig, Reason, Opts)
+ end;
+acquire_credential(repo, BaseConfig, Reason, Opts) ->
+ CurrentRepoKey = maps:get(repo_key, BaseConfig, undefined),
+ case resolve_repo_auth(BaseConfig) of
+ {ok, RepoKey, AuthContext} when is_binary(RepoKey), RepoKey =/= CurrentRepoKey ->
+ {ok, RepoKey, AuthContext};
_ ->
- prompt_and_device_auth(BaseConfig, Fun, Reason, Opts)
+ prompt_and_device_auth(repo, BaseConfig, Reason, Opts)
end.
%% @private
-prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) ->
+prompt_and_device_auth(Kind, BaseConfig, 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);
+ {ok, Tokens} ->
+ authenticated_credential(Kind, BaseConfig, Tokens);
{error, _} = Error ->
Error
end;
@@ -379,14 +447,37 @@ prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) ->
{error, {auth_error, auth_declined}}
end.
+%% @private
+%% The credential device auth just produced. The token is the user's API token;
+%% what a repository request needs is repository auth, which the token may only
+%% be one input to, so resolve that instead of reusing the API-shaped one.
+authenticated_credential(api, _BaseConfig, #{access_token := AccessToken} = Tokens) ->
+ {ok, <<"Bearer ", AccessToken/binary>>, #{has_refresh_token => has_refresh_token(Tokens)}};
+authenticated_credential(repo, BaseConfig, _Tokens) ->
+ case resolve_repo_auth(BaseConfig) of
+ {ok, RepoKey, AuthContext} when is_binary(RepoKey) ->
+ {ok, RepoKey, AuthContext};
+ no_auth ->
+ {error, {auth_error, no_credentials}};
+ {error, _} = Error ->
+ Error
+ end.
+
+%% @private
+execute_authenticated(api, BaseConfig, Fun, ApiKey, AuthContext, Retries, Opts) ->
+ execute_with_retry(BaseConfig#{api_key => ApiKey}, Fun, AuthContext, Retries, Opts);
+execute_authenticated(repo, BaseConfig, Fun, RepoKey, _AuthContext, _Retries, _Opts) ->
+ execute_repo_with_retry(BaseConfig, Fun, RepoKey).
+
%% @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),
+execute_optional_with_retry(Kind, BaseConfig, Fun, AuthInline, Opts) ->
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);
+ maybe_authenticate_and_retry(
+ Kind, BaseConfig, Fun, no_credentials, initial_retries(), Opts
+ );
{ok, {401, _Headers, _Body}} ->
%% Got 401 but auth_inline is false, return error
{error, {auth_error, no_credentials}};
@@ -394,6 +485,61 @@ execute_optional_with_retry(BaseConfig, Fun, Opts) ->
Other
end.
+%% @private
+%% Run a repository request with a resolved token. A 401 that says the token
+%% expired is answered by renewing the credential at its source and running the
+%% request once more; a second 401 is the caller's to handle.
+execute_repo_with_retry(BaseConfig, Fun, RepoKey) ->
+ case Fun(BaseConfig#{repo_key => RepoKey}) of
+ {ok, {401, Headers, _Body}} = Response ->
+ case detect_auth_error(Headers) of
+ token_expired ->
+ renew_repo_auth_and_retry(BaseConfig, Fun, RepoKey, Response);
+ _Other ->
+ Response
+ end;
+ Other ->
+ Other
+ end.
+
+%% @private
+renew_repo_auth_and_retry(BaseConfig, Fun, RepoKey, Response) ->
+ case resolve_repo_auth(BaseConfig, true) of
+ {ok, NewRepoKey, _AuthContext} when is_binary(NewRepoKey), NewRepoKey =/= RepoKey ->
+ Fun(BaseConfig#{repo_key => NewRepoKey});
+ _Other ->
+ Response
+ end.
+
+%% @doc
+%% 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 against an organization's identity provider 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 refresh_tokens(hex_core:config()) -> ok | {error, auth_error()}.
+refresh_tokens(Config) ->
+ Refresh = fun(Tokens) ->
+ %% A failed refresh leaves the stored token in place; the caller warns
+ %% and continues with it.
+ case maybe_refresh_token_with_context(Config, Tokens) of
+ {ok, _BearerToken, _AuthContext} -> ok;
+ {error, _Reason} = Error -> Error
+ end
+ end,
+ case with_token_refresh_lock(Config, Refresh) of
+ error -> {error, {auth_error, no_credentials}};
+ Result -> Result
+ 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 - Device Auth
%%====================================================================
@@ -412,15 +558,14 @@ device_auth(Config, Scope, Opts) ->
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
- }};
+ {ok, Response} ->
+ %% sso_reauth_required reaches the build tool through the sso_reauth
+ %% callback rather than with the tokens. The response carries no key
+ %% when the server sent a set that could not be read.
+ Tokens = maps:without([sso_reauth_required], Response),
+ ok = persist_tokens(Config, global, Tokens),
+ report_sso_reauth(Config, maps:find(sso_reauth_required, Response)),
+ {ok, Tokens};
{error, timeout} ->
{error, {auth_error, device_auth_timeout}};
{error, {access_denied, _Status, _Body}} ->
@@ -433,13 +578,6 @@ device_auth(Config, Scope, Opts) ->
{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
%%====================================================================
@@ -449,21 +587,21 @@ is_token_expired(ExpiresAt) ->
{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}};
+ {ok, ApiKey, #{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}};
+ {ok, ApiKey, #{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}};
+ {ok, ApiKey, #{has_refresh_token => false}};
error ->
%% 3. Try global OAuth token
- resolve_oauth_token_with_context(Config)
+ resolve_oauth_token_with_context(Config, false)
end
end.
@@ -477,46 +615,52 @@ resolve_api_auth(_Permission, Config) ->
%% 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) ->
+ 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.
+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}};
+resolve_repo_auth(Config, Renew) ->
RepoName = repo_name(Config),
global:trans(
{{?MODULE, repo, RepoName}, self()},
fun() ->
- do_resolve_repo_auth(RepoName, RepoName, Config)
+ do_resolve_repo_auth(RepoName, RepoName, Config, Renew)
end,
[node()],
infinity
).
-do_resolve_repo_auth(RepoName, LookupRepo, Config) ->
+do_resolve_repo_auth(RepoName, LookupRepo, Config, Renew) ->
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}};
+ {ok, RepoKey, #{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);
+ resolve_repo_oauth_token(RepoName, Config, AuthKey, OAuthToken, Renew);
#{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}};
+ {ok, AuthKey, #{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);
+ do_resolve_repo_auth(RepoName, ParentName, Config, Renew);
_ ->
%% 6. trusted Hex.pm or child repository + global OAuth tokens => use those
- resolve_global_oauth_for_repo(RepoName, Config)
+ resolve_global_oauth_for_repo(RepoName, Config, Renew)
end;
_ ->
%% 7. Not trusted, no auth
@@ -524,15 +668,15 @@ do_resolve_repo_auth(RepoName, LookupRepo, Config) ->
end.
%% @private
-resolve_global_oauth_for_repo(<<"hexpm">>, Config) ->
- resolve_global_oauth_for_repo(Config);
-resolve_global_oauth_for_repo(<<"hexpm:", _/binary>>, Config) ->
- resolve_global_oauth_for_repo(Config);
-resolve_global_oauth_for_repo(_RepoName, _Config) ->
+resolve_global_oauth_for_repo(<<"hexpm">>, Config, Renew) ->
+ resolve_global_oauth_for_repo(Config, Renew);
+resolve_global_oauth_for_repo(<<"hexpm:", _/binary>>, Config, Renew) ->
+ resolve_global_oauth_for_repo(Config, Renew);
+resolve_global_oauth_for_repo(_RepoName, _Config, _Renew) ->
no_auth.
-resolve_global_oauth_for_repo(Config) ->
- case resolve_oauth_token_with_context(Config) of
+resolve_global_oauth_for_repo(Config, Renew) ->
+ case resolve_oauth_token_with_context(Config, Renew) of
{ok, Token, AuthContext} ->
{ok, Token, AuthContext};
{error, no_auth} ->
@@ -542,15 +686,19 @@ resolve_global_oauth_for_repo(Config) ->
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
+%% Resolve repo OAuth token: use if valid, re-exchange if expiring or rejected.
+resolve_repo_oauth_token(
+ RepoName,
+ Config,
+ AuthKey,
+ #{access_token := AccessToken, expires_at := ExpiresAt},
+ Renew
+) ->
+ case Renew orelse is_token_expired(ExpiresAt) of
false ->
%% Token is still valid, use it
BearerToken = <<"Bearer ", AccessToken/binary>>,
- {ok, BearerToken, #{source => oauth, has_refresh_token => false}};
+ {ok, BearerToken, #{has_refresh_token => false}};
true ->
%% Token expired, do a new exchange
exchange_for_oauth_token(RepoName, Config, AuthKey, <<"repositories">>)
@@ -568,12 +716,13 @@ exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) ->
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
- ]),
+ Tokens = #{
+ access_token => AccessToken,
+ expires_at => erlang:system_time(second) + ExpiresIn
+ },
+ ok = persist_tokens(Config, RepoName, Tokens),
BearerToken = <<"Bearer ", AccessToken/binary>>,
- {ok, BearerToken, #{source => oauth, has_refresh_token => false}};
+ {ok, BearerToken, #{has_refresh_token => false}};
{ok, {_Status, _, _Body}} ->
{error, {auth_error, oauth_exchange_failed}};
{error, _} ->
@@ -596,93 +745,142 @@ get_parent_repo_key(Config, RepoName, KeyType) ->
%% @private
%% Resolve OAuth token with global lock to prevent concurrent refresh attempts.
-resolve_oauth_token_with_context(Config) ->
+%% Renew refreshes a token that has not run out of time, for when the server
+%% has rejected it anyway.
+resolve_oauth_token_with_context(Config, Renew) ->
+ Resolve = fun(#{access_token := AccessToken, expires_at := ExpiresAt} = Tokens) ->
+ case Renew orelse is_token_expired(ExpiresAt) of
+ true ->
+ refresh_or_clear(Config, Tokens);
+ false ->
+ BearerToken = <<"Bearer ", AccessToken/binary>>,
+ {ok, BearerToken, #{has_refresh_token => has_refresh_token(Tokens)}}
+ end
+ end,
+ case with_token_refresh_lock(Config, Resolve) of
+ error -> {error, no_auth};
+ Result -> Result
+ end.
+
+%% @private
+%% Fetch the stored global tokens and hand them to Fun under the token-refresh
+%% lock, so concurrent callers do not each refresh the same token. Returns
+%% `error' without calling Fun when no tokens are stored.
+with_token_refresh_lock(Config, Fun) ->
global:trans(
{{?MODULE, token_refresh}, self()},
fun() ->
- do_resolve_oauth_token_with_context(Config)
+ case call_callback(Config, get_oauth_tokens, []) of
+ {ok, Tokens} -> Fun(Tokens);
+ error -> error
+ end
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 an expired global token; if the server rejected the refresh token,
+%% 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. A refresh
+%% that got no usable answer says nothing about the token, so it is kept.
refresh_or_clear(Config, Tokens) ->
case maybe_refresh_token_with_context(Config, Tokens) of
{ok, _Bearer, _Ctx} = Ok ->
Ok;
- {error, _} = Error ->
+ {error, {auth_error, token_refresh_failed}} = Error ->
maybe_call_callback(Config, clear_oauth_tokens, []),
+ Error;
+ {error, _} = Error ->
Error
end.
%% @private
+%% Only 400 and 401 are the server refusing the refresh token, which is what
+%% makes the stored credential dead. A 429, a 5xx and a 200 whose body cannot be
+%% read all leave the refresh token as good as it was.
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
- ]),
+ {ok,
+ {200, _,
+ #{
+ <<"access_token">> := NewAccessToken,
+ <<"expires_in">> := ExpiresIn
+ } = TokenResponse}} when
+ is_binary(NewAccessToken), is_integer(ExpiresIn)
+ ->
+ NewTokens = #{
+ access_token => NewAccessToken,
+ refresh_token => new_refresh_token(TokenResponse, RefreshToken),
+ expires_at => erlang:system_time(second) + ExpiresIn
+ },
+ ok = persist_tokens(Config, global, NewTokens),
+ report_sso_reauth(Config, hex_api_oauth:sso_reauth_required(TokenResponse)),
BearerToken = <<"Bearer ", NewAccessToken/binary>>,
- HasRefreshToken = is_binary(NewRefreshToken),
- {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}};
- {ok, {_Status, _, _Body}} ->
+ {ok, BearerToken, #{has_refresh_token => has_refresh_token(NewTokens)}};
+ {ok, {Status, _, _Body}} when Status =:= 400; Status =:= 401 ->
{error, {auth_error, token_refresh_failed}};
+ {ok, {_Status, _, _Body}} ->
+ {error, {auth_error, token_refresh_unavailable}};
{error, _Reason} ->
- {error, {auth_error, token_refresh_failed}}
+ {error, {auth_error, token_refresh_unavailable}}
end;
maybe_refresh_token_with_context(_Config, _Tokens) ->
{error, {auth_error, token_refresh_failed}}.
+%% @private
+%% A refresh that does not rotate the refresh token keeps the one we sent.
+new_refresh_token(#{<<"refresh_token">> := RefreshToken}, _CurrentRefreshToken) when
+ is_binary(RefreshToken)
+->
+ RefreshToken;
+new_refresh_token(_TokenResponse, CurrentRefreshToken) ->
+ CurrentRefreshToken.
+
+%% @private
+%% Whether these tokens can be refreshed.
+-spec has_refresh_token(oauth_tokens()) -> boolean().
+has_refresh_token(Tokens) ->
+ is_binary(maps:get(refresh_token, Tokens, undefined)).
+
+%% @private
+%% The one place tokens are handed to the build tool for storage. A token map
+%% without a refresh token is persisted as `undefined', which is what the
+%% persist_oauth_tokens callback documents for "there is none".
+-spec persist_tokens(hex_core:config(), global | binary(), oauth_tokens()) -> ok.
+persist_tokens(Config, Scope, #{access_token := AccessToken, expires_at := ExpiresAt} = Tokens) ->
+ RefreshToken = maps:get(refresh_token, Tokens, undefined),
+ ok = call_callback(Config, persist_oauth_tokens, [
+ Scope, AccessToken, RefreshToken, ExpiresAt
+ ]).
+
%%====================================================================
%% Internal functions - Retry Logic
%%====================================================================
%% @private
-execute_with_retry(Config, Fun, AuthContext, OtpRetries, LastOtpError, Opts) ->
+-spec initial_retries() -> retries().
+initial_retries() ->
+ #{otp => 0, otp_error => undefined, token => 0}.
+
+%% @private
+execute_with_retry(Config, Fun, AuthContext, Retries, Opts) ->
case Fun(Config) of
{error, otp_required} ->
handle_otp_retry(
- Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts
+ Config, Fun, AuthContext, Retries, <<"Enter OTP code:">>, Opts
);
{error, invalid_totp} ->
handle_otp_retry(
Config,
Fun,
AuthContext,
- OtpRetries,
+ Retries,
<<"Invalid OTP code. Please try again:">>,
Opts
);
@@ -690,17 +888,17 @@ execute_with_retry(Config, Fun, AuthContext, OtpRetries, LastOtpError, Opts) ->
case detect_auth_error(Headers) of
otp_required ->
handle_otp_retry(
- Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts
+ Config, Fun, AuthContext, Retries, <<"Enter OTP code:">>, Opts
);
invalid_totp ->
Msg =
- case LastOtpError of
+ case maps:get(otp_error, Retries) of
invalid_totp -> <<"Invalid OTP code. Please try again:">>;
_ -> <<"Enter OTP code:">>
end,
- handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Msg, Opts);
+ handle_otp_retry(Config, Fun, AuthContext, Retries, Msg, Opts);
token_expired ->
- handle_token_refresh_retry(Config, Fun, AuthContext, Opts);
+ handle_token_refresh_retry(Config, Fun, AuthContext, Retries, Response, Opts);
none ->
Response
end;
@@ -709,47 +907,57 @@ execute_with_retry(Config, Fun, AuthContext, OtpRetries, LastOtpError, Opts) ->
end.
%% @private
-handle_otp_retry(_Config, _Fun, _AuthContext, OtpRetries, _Message, _Opts) when
+handle_otp_retry(_Config, _Fun, _AuthContext, #{otp := OtpRetries}, _Message, _Opts) when
OtpRetries >= ?MAX_OTP_RETRIES
->
{error, {auth_error, otp_max_retries}};
-handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Message, Opts) ->
+handle_otp_retry(Config, Fun, AuthContext, #{otp := OtpRetries} = Retries, 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
- );
+ NewRetries = Retries#{otp := OtpRetries + 1, otp_error := invalid_totp},
+ execute_with_retry(NewConfig, Fun, AuthContext, NewRetries, Opts);
cancelled ->
{error, {auth_error, otp_cancelled}}
end.
%% @private
-handle_token_refresh_retry(Config, Fun, AuthContext, Opts) ->
+%% A 401 that says the token expired is answered by renewing the credential at
+%% its source and running the request once more, the way a repository request
+%% is. The renewal is counted, so a server that answers token_expired to every
+%% bearer we send it gets a bounded number of requests rather than a loop.
+handle_token_refresh_retry(
+ _Config, _Fun, _AuthContext, #{token := TokenRetries}, Response, _Opts
+) when
+ TokenRetries >= ?MAX_TOKEN_RETRIES
+->
+ Response;
+handle_token_refresh_retry(
+ Config, Fun, AuthContext, #{token := TokenRetries} = Retries, _Response, Opts
+) ->
+ NewRetries = Retries#{token := TokenRetries + 1},
%% 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
+ case resolve_oauth_token_with_context(Config, true) of
{ok, NewBearerToken, NewAuthContext} ->
NewConfig = Config#{api_key => NewBearerToken},
- execute_with_retry(
- NewConfig, Fun, NewAuthContext, 0, undefined, Opts
- );
+ execute_with_retry(NewConfig, Fun, NewAuthContext, NewRetries, Opts);
{error, _} ->
- maybe_reauthenticate(Config, Fun, Opts)
+ maybe_reauthenticate(Config, Fun, NewRetries, Opts)
end;
false ->
- maybe_reauthenticate(Config, Fun, Opts)
+ maybe_reauthenticate(Config, Fun, NewRetries, 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) ->
+maybe_reauthenticate(Config, Fun, Retries, Opts) ->
AuthInline = proplists:get_value(auth_inline, Opts, true),
case AuthInline of
true ->
- maybe_authenticate_and_retry(Config, Fun, token_refresh_failed, Opts);
+ maybe_authenticate_and_retry(api, Config, Fun, token_refresh_failed, Retries, Opts);
false ->
{error, {auth_error, token_refresh_failed}}
end.
@@ -787,6 +995,17 @@ call_callback(Config, Name, Args) ->
Fun = maps:get(Name, Callbacks),
erlang:apply(Fun, Args).
+%% @private
+%% Hands the build tool the organizations this session has to authenticate for.
+%% A grant that flagged none carries the empty list, so a set that has been
+%% resolved does not linger. A grant whose set could not be read is not
+%% reported: the empty list would be taken for the server saying there is
+%% nothing, and the build tool would drop the organizations it holds.
+report_sso_reauth(Config, {ok, Organizations}) when is_list(Organizations) ->
+ maybe_call_callback(Config, sso_reauth, [Organizations]);
+report_sso_reauth(_Config, error) ->
+ ok.
+
%% @private
%% Like call_callback/3 but for optional callbacks: returns ok without doing
%% anything when the callback is not provided.
diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl
index 78412f3..53a3335 100644
--- a/test/hex_api_SUITE.erl
+++ b/test/hex_api_SUITE.erl
@@ -32,7 +32,17 @@ all() ->
oauth_device_auth_flow_success_test,
oauth_device_auth_flow_denied_test,
oauth_device_auth_flow_timeout_test,
+ oauth_device_auth_flow_poll_error_test,
+ oauth_device_auth_flow_no_refresh_token_test,
+ oauth_device_auth_flow_invalid_verification_uri_test,
+ oauth_device_auth_flow_malformed_device_response_test,
+ oauth_device_auth_flow_malformed_token_response_test,
oauth_refresh_token_test,
+ oauth_sso_authorization_test,
+ oauth_device_auth_flow_sso_reauth_test,
+ oauth_device_auth_flow_malformed_sso_reauth_test,
+ oauth_sso_reauth_required_test,
+ oauth_win_cmd_args_escapes_metacharacters_test,
oauth_revoke_test,
oauth_client_credentials_test,
publish_with_expect_header_test,
@@ -224,6 +234,163 @@ oauth_device_auth_flow_timeout_test(_Config) ->
{error, timeout} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),
ok.
+oauth_device_auth_flow_poll_error_test(_Config) ->
+ % A poll that fails to reach the server keeps polling: the authorization the
+ % user is part way through outlives one dropped request.
+ ClientId = <<"cli">>,
+ Scope = <<"api:write">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+
+ SuccessPayload = #{
+ <<"access_token">> => <<"test_access_token">>,
+ <<"refresh_token">> => <<"test_refresh_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600
+ },
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self ! {hex_http_test, oauth_device_response, {error, timeout}},
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(SuccessPayload)}}},
+
+ {ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),
+
+ ?assertEqual(<<"test_access_token">>, maps:get(access_token, Tokens)),
+ ok.
+
+oauth_device_auth_flow_no_refresh_token_test(_Config) ->
+ % A grant without a refresh token carries no key, rather than a placeholder
+ % a build tool would go on to store as if it were a token.
+ ClientId = <<"cli">>,
+ Scope = <<"api:write">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+
+ SuccessPayload = #{
+ <<"access_token">> => <<"test_access_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600
+ },
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(SuccessPayload)}}},
+
+ {ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),
+
+ ?assertNot(maps:is_key(refresh_token, Tokens)),
+ ok.
+
+oauth_device_auth_flow_invalid_verification_uri_test(_Config) ->
+ % A verification URI that is not http(s) is not opened, and not a reason to
+ % end the flow either.
+ ClientId = <<"cli">>,
+ Scope = <<"api:write">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+
+ DevicePayload = #{
+ <<"device_code">> => <<"device_code">>,
+ <<"user_code">> => <<"1234-5678">>,
+ <<"verification_uri">> => <<"javascript:alert(1)">>,
+ <<"verification_uri_complete">> => <<"javascript:alert(1)">>,
+ <<"expires_in">> => 600,
+ <<"interval">> => 0
+ },
+ Self !
+ {hex_http_test, oauth_device_authorization_response,
+ {ok, {200, Headers, term_to_binary(DevicePayload)}}},
+
+ SuccessPayload = #{
+ <<"access_token">> => <<"test_access_token">>,
+ <<"refresh_token">> => <<"test_refresh_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600
+ },
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(SuccessPayload)}}},
+
+ {ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser, [
+ {open_browser, true}
+ ]),
+
+ ?assertEqual(<<"test_access_token">>, maps:get(access_token, Tokens)),
+ ok.
+
+oauth_device_auth_flow_malformed_device_response_test(_Config) ->
+ % A 200 that does not carry the fields the flow uses is a failed device
+ % authorization, not a badmatch or a timer:sleep/1 badarg in the caller.
+ ClientId = <<"cli">>,
+ Scope = <<"api:write">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> error(prompt_called) end,
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+
+ Complete = #{
+ <<"device_code">> => <<"device_code">>,
+ <<"user_code">> => <<"1234-5678">>,
+ <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=1234-5678">>,
+ <<"expires_in">> => 600,
+ <<"interval">> => 0
+ },
+ Malformed = [
+ maps:remove(<<"device_code">>, Complete),
+ maps:remove(<<"verification_uri_complete">>, Complete),
+ Complete#{<<"interval">> => <<"5">>},
+ Complete#{<<"interval">> => -1},
+ Complete#{<<"expires_in">> => <<"600">>},
+ Complete#{<<"verification_uri_complete">> => 42},
+ <<"not a map">>
+ ],
+
+ [
+ begin
+ Self !
+ {hex_http_test, oauth_device_authorization_response,
+ {ok, {200, Headers, term_to_binary(Payload)}}},
+ ?assertEqual(
+ {error, {device_auth_failed, 200, Payload}},
+ hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser)
+ )
+ end
+ || Payload <- Malformed
+ ],
+ ok.
+
+oauth_device_auth_flow_malformed_token_response_test(_Config) ->
+ % Same for the poll: a 200 without a usable access token ends the flow with
+ % an error the caller already handles.
+ ClientId = <<"cli">>,
+ Scope = <<"api:write">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+
+ Malformed = [
+ #{<<"expires_in">> => 3600},
+ #{<<"access_token">> => <<"test_access_token">>},
+ #{<<"access_token">> => <<"test_access_token">>, <<"expires_in">> => <<"3600">>},
+ #{<<"access_token">> => 42, <<"expires_in">> => 3600},
+ <<"not a map">>
+ ],
+
+ [
+ begin
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(Payload)}}},
+ ?assertEqual(
+ {error, {poll_failed, 200, Payload}},
+ hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser)
+ )
+ end
+ || Payload <- Malformed
+ ],
+ ok.
+
oauth_refresh_token_test(_Config) ->
% Test token refresh
ClientId = <<"cli">>,
@@ -242,6 +409,114 @@ oauth_refresh_token_test(_Config) ->
?assert(is_integer(ExpiresIn)),
ok.
+oauth_sso_authorization_test(_Config) ->
+ {ok, {201, _, Response}} = hex_api_oauth:sso_authorization(?CONFIG, [<<"acme">>]),
+ #{
+ <<"verification_uri">> := VerificationUri,
+ <<"expires_in">> := ExpiresIn
+ } = Response,
+ ?assertEqual(<<"https://hex.pm/sso/authorize/acme">>, VerificationUri),
+ ?assert(is_integer(ExpiresIn)),
+ ok.
+
+oauth_device_auth_flow_sso_reauth_test(_Config) ->
+ % The organizations a token was minted without reach the caller
+ ClientId = <<"cli">>,
+ Scope = <<"repositories">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+
+ SuccessPayload = #{
+ <<"access_token">> => <<"test_access_token">>,
+ <<"refresh_token">> => <<"test_refresh_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600,
+ <<"sso_reauth_required">> => [<<"acme">>]
+ },
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(SuccessPayload)}}},
+
+ {ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),
+
+ ?assertEqual([<<"acme">>], maps:get(sso_reauth_required, Tokens)),
+ ok.
+
+oauth_device_auth_flow_malformed_sso_reauth_test(_Config) ->
+ % A set the server sent in a shape we cannot read carries no key at all. The
+ % empty list means "nothing lapsed", which is not what the response said.
+ ClientId = <<"cli">>,
+ Scope = <<"repositories">>,
+ Self = self(),
+ PromptUser = fun(_VerificationUri, _UserCode) -> ok end,
+
+ SuccessPayload = #{
+ <<"access_token">> => <<"test_access_token">>,
+ <<"refresh_token">> => <<"test_refresh_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600,
+ <<"sso_reauth_required">> => <<"acme">>
+ },
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_device_response,
+ {ok, {200, Headers, term_to_binary(SuccessPayload)}}},
+
+ {ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),
+
+ ?assertNot(maps:is_key(sso_reauth_required, Tokens)),
+ ok.
+
+oauth_sso_reauth_required_test(_Config) ->
+ % A response that does not carry the field is a server that predates it and
+ % means nothing is lapsed; one that carries an unreadable value means the
+ % response says nothing at all.
+ ?assertEqual({ok, []}, hex_api_oauth:sso_reauth_required(#{})),
+ ?assertEqual(
+ {ok, []},
+ hex_api_oauth:sso_reauth_required(#{<<"sso_reauth_required">> => []})
+ ),
+ ?assertEqual(
+ {ok, [<<"acme">>]},
+ hex_api_oauth:sso_reauth_required(#{<<"sso_reauth_required">> => [<<"acme">>]})
+ ),
+ ?assertEqual(
+ error,
+ hex_api_oauth:sso_reauth_required(#{<<"sso_reauth_required">> => <<"acme">>})
+ ),
+ ?assertEqual(
+ error,
+ hex_api_oauth:sso_reauth_required(#{<<"sso_reauth_required">> => [<<"acme">>, 42]})
+ ),
+ ?assertEqual(
+ error,
+ hex_api_oauth:sso_reauth_required(#{<<"sso_reauth_required">> => null})
+ ),
+ ok.
+
+oauth_win_cmd_args_escapes_metacharacters_test(_Config) ->
+ % cmd.exe parses the command line before `start' sees it, and erts only
+ % quotes an argument containing whitespace, so a server-supplied URL reaches
+ % cmd with its separators inert or it runs whatever trails them.
+ ?assertEqual(
+ ["/c", "start", "", "https://example.com/^&calc.exe"],
+ hex_api_oauth:win_cmd_args("https://example.com/&calc.exe")
+ ),
+ ?assertEqual(
+ ["/c", "start", "", "https://example.com/^%PATH^%"],
+ hex_api_oauth:win_cmd_args("https://example.com/%PATH%")
+ ),
+ ?assertEqual(
+ ["/c", "start", "", "^^^&^|^<^>^(^)^\"^%"],
+ hex_api_oauth:win_cmd_args("^&|<>()\"%")
+ ),
+ ?assertEqual(
+ ["/c", "start", "", "https://example.com/plain"],
+ hex_api_oauth:win_cmd_args("https://example.com/plain")
+ ),
+ ok.
+
oauth_revoke_test(_Config) ->
% Test token revocation
ClientId = <<"cli">>,
diff --git a/test/hex_cli_auth_SUITE.erl b/test/hex_cli_auth_SUITE.erl
index 25e7d74..451ce02 100644
--- a/test/hex_cli_auth_SUITE.erl
+++ b/test/hex_cli_auth_SUITE.erl
@@ -52,8 +52,18 @@ all() ->
with_api_otp_cancelled_test,
with_api_otp_max_retries_test,
+ %% sso re-authorization
+ sso_reauth_reported_on_refresh_test,
+ sso_reauth_reported_empty_test,
+ sso_reauth_malformed_not_reported_test,
+ refresh_tokens_forces_a_refresh_test,
+ refresh_tokens_without_credentials_test,
+
%% with_api tests - token refresh on 401
with_api_token_expired_refresh_test,
+ with_api_token_expired_renews_test,
+ with_api_token_expired_retry_bounded_test,
+ with_api_token_expired_reauth_retry_bounded_test,
%% with_api tests - reauthentication after refresh failure
with_api_token_expired_reauth_yes_test,
@@ -63,6 +73,7 @@ all() ->
%% with_api tests - wrapper behavior
with_api_optional_test,
with_api_optional_token_refresh_failed_test,
+ with_api_refused_refresh_prompts_test,
with_api_auth_inline_test,
with_api_device_auth_test,
@@ -70,11 +81,22 @@ all() ->
with_repo_optional_test,
with_repo_trusted_with_auth_test,
with_repo_optional_token_refresh_failed_test,
+ with_repo_optional_401_does_not_prompt_test,
+ with_repo_device_auth_sets_repo_key_test,
+ with_repo_token_expired_refresh_test,
+ with_repo_token_expired_exchange_test,
+
+ %% token refresh failure modes
+ refresh_transport_error_keeps_token_test,
+ refresh_server_error_keeps_token_test,
+ refresh_malformed_body_keeps_token_test,
+ refresh_refusal_clears_token_test,
%% concurrency tests
resolve_oauth_token_concurrent_refresh_serialized_test,
resolve_oauth_token_refresh_failure_clears_once_test,
- device_auth_concurrent_serialized_reuses_login_test
+ device_auth_concurrent_serialized_reuses_login_test,
+ device_auth_lock_released_before_request_test
].
%%====================================================================
@@ -88,7 +110,7 @@ resolve_api_auth_config_passthrough_test(_Config) ->
{ok, ApiKey, AuthContext} = hex_cli_auth:resolve_api_auth(read, ConfigWithKey),
?assertEqual(<<"config_api_key">>, ApiKey),
- ?assertEqual(#{source => config, has_refresh_token => false}, AuthContext),
+ ?assertEqual(#{has_refresh_token => false}, AuthContext),
ok.
resolve_api_auth_per_repo_test(_Config) ->
@@ -99,7 +121,7 @@ resolve_api_auth_per_repo_test(_Config) ->
{ok, ApiKey, AuthContext} = hex_cli_auth:resolve_api_auth(write, Config),
?assertEqual(<<"repo_api_key">>, ApiKey),
- ?assertEqual(#{source => config, has_refresh_token => false}, AuthContext),
+ ?assertEqual(#{has_refresh_token => false}, AuthContext),
ok.
resolve_api_auth_parent_repo_test(_Config) ->
@@ -128,7 +150,7 @@ resolve_api_auth_oauth_test(_Config) ->
{ok, ApiKey, AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
?assertEqual(<<"Bearer oauth_token">>, ApiKey),
- ?assertEqual(#{source => oauth, has_refresh_token => true}, AuthContext),
+ ?assertEqual(#{has_refresh_token => true}, AuthContext),
ok.
resolve_api_auth_oauth_expired_refresh_test(_Config) ->
@@ -152,7 +174,7 @@ resolve_api_auth_oauth_expired_refresh_test(_Config) ->
{ok, ApiKey, AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
%% Should have refreshed and got a new token
?assertMatch(<<"Bearer ", _/binary>>, ApiKey),
- ?assertEqual(#{source => oauth, has_refresh_token => true}, AuthContext),
+ ?assertEqual(#{has_refresh_token => true}, AuthContext),
%% Verify token was persisted
receive
@@ -175,7 +197,7 @@ resolve_api_auth_oauth_no_refresh_token_test(_Config) ->
{ok, ApiKey, AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
?assertEqual(<<"Bearer oauth_token">>, ApiKey),
- ?assertEqual(#{source => oauth, has_refresh_token => false}, AuthContext),
+ ?assertEqual(#{has_refresh_token => false}, AuthContext),
ok.
resolve_api_auth_no_auth_test(_Config) ->
@@ -200,7 +222,7 @@ resolve_repo_auth_config_passthrough_test(_Config) ->
{ok, RepoKey, AuthContext} = hex_cli_auth:resolve_repo_auth(ConfigWithKey),
?assertEqual(<<"config_repo_key">>, RepoKey),
- ?assertEqual(#{source => config, has_refresh_token => false}, AuthContext),
+ ?assertEqual(#{has_refresh_token => false}, AuthContext),
ok.
resolve_repo_auth_callback_repo_key_test(_Config) ->
@@ -307,7 +329,7 @@ resolve_repo_auth_oauth_exchange_new_token_test(_Config) ->
Config#{trusted => true, oauth_exchange => true}
),
?assertMatch(<<"Bearer ", _/binary>>, RepoKey),
- ?assertEqual(#{source => oauth, has_refresh_token => false}, AuthContext),
+ ?assertEqual(#{has_refresh_token => false}, AuthContext),
%% Verify token was persisted with repo name
receive
@@ -596,6 +618,97 @@ with_api_token_expired_refresh_test(_Config) ->
end,
ok.
+with_api_token_expired_renews_test(_Config) ->
+ %% An API token the server rejects as expired is refreshed even though its
+ %% stored expiry has not passed, the way a repository token is, and the
+ %% request runs again with the token that came back.
+ Now = erlang:system_time(second),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"stale_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now + 3600
+ }}
+ }),
+
+ queue_refresh_response(#{<<"access_token">> => <<"renewed_token">>}),
+
+ Fun = fun(Cfg) ->
+ case maps:get(api_key, Cfg) of
+ <<"Bearer stale_token">> -> token_expired_response();
+ ApiKey -> {ok, {200, #{}, ApiKey}}
+ end
+ end,
+
+ ?assertEqual(
+ {ok, {200, #{}, <<"Bearer renewed_token">>}},
+ hex_cli_auth:with_api(write, Config, Fun)
+ ),
+ ok.
+
+with_api_token_expired_retry_bounded_test(_Config) ->
+ %% A server that answers token_expired to every bearer it is sent gets a
+ %% bounded number of requests: the renewed token is tried once and the 401
+ %% is handed back, rather than renewed and retried without end.
+ Now = erlang:system_time(second),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"stale_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now + 3600
+ }},
+ should_authenticate => fun(_Reason) -> error(should_not_be_called) end
+ }),
+
+ CallCount = counters:new(1, []),
+ Result = hex_cli_auth:with_api(write, Config, fun(_Cfg) ->
+ counters:add(CallCount, 1, 1),
+ token_expired_response()
+ end),
+
+ ?assertMatch({ok, {401, _Headers, _Body}}, Result),
+ ?assertEqual(2, counters:get(CallCount, 1)),
+ ok.
+
+with_api_token_expired_reauth_retry_bounded_test(_Config) ->
+ %% Same bound when the renewal is a device auth: the user authenticates
+ %% once, the request runs again with the new token, and a second
+ %% token_expired is the caller's to handle rather than a second prompt.
+ Now = erlang:system_time(second),
+ PromptCount = counters:new(1, []),
+ CallCount = counters:new(1, []),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"initial_token">>,
+ %% No refresh_token, so the 401 goes straight to reauth
+ expires_at => Now + 3600
+ }},
+ should_authenticate => fun(token_refresh_failed) ->
+ counters:add(PromptCount, 1, 1),
+ true
+ end
+ }),
+
+ queue_device_response(<<"device_token">>),
+
+ Result = hex_cli_auth:with_api(
+ write,
+ Config,
+ fun(_Cfg) ->
+ counters:add(CallCount, 1, 1),
+ token_expired_response()
+ end,
+ [{oauth_open_browser, false}]
+ ),
+
+ ?assertMatch({ok, {401, _Headers, _Body}}, Result),
+ ?assertEqual(1, counters:get(PromptCount, 1)),
+ ?assertEqual(2, counters:get(CallCount, 1)),
+ ok.
+
%%====================================================================
%% Test Cases - with_api reauthentication after refresh failure
%%====================================================================
@@ -786,6 +899,41 @@ with_api_optional_token_refresh_failed_test(_Config) ->
?assertEqual(undefined, Result),
ok.
+with_api_refused_refresh_prompts_test(_Config) ->
+ %% A refused refresh leaves no usable token, same as having none, so the
+ %% caller that asked to be prompted up front is asked rather than told to
+ %% run mix hex.user auth.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ expires_at => Now - 100
+ }},
+ should_authenticate => fun(Reason) ->
+ Self ! {should_authenticate, Reason},
+ false
+ end
+ }),
+
+ Result = hex_cli_auth:with_api(
+ write,
+ Config,
+ fun(_) -> error(should_not_be_called) end,
+ [{optional, false}, {auth_inline, true}]
+ ),
+
+ receive
+ {should_authenticate, Reason} ->
+ ?assertEqual(token_refresh_failed, Reason)
+ after 0 ->
+ ct:fail("should_authenticate was never called")
+ end,
+
+ ?assertEqual({error, {auth_error, auth_declined}}, Result),
+ ok.
+
with_api_auth_inline_test(_Config) ->
%% Test auth_inline => false returns error instead of prompting
Config = config_with_callbacks(#{oauth_tokens => error}),
@@ -889,6 +1037,261 @@ with_repo_optional_token_refresh_failed_test(_Config) ->
?assertEqual(undefined, Result),
ok.
+with_repo_optional_401_does_not_prompt_test(_Config) ->
+ %% with_repo defaults auth_inline to false, so a 401 on a private package
+ %% returns instead of opening a device auth flow the caller did not ask for.
+ Self = self(),
+ Config = config_with_callbacks(#{
+ should_authenticate => fun(_Reason) ->
+ Self ! prompted,
+ false
+ end
+ }),
+
+ Result = hex_cli_auth:with_repo(
+ Config#{trusted => true},
+ fun(_Cfg) -> {ok, {401, #{}, <<"">>}} end
+ ),
+ ?assertEqual({error, {auth_error, no_credentials}}, Result),
+
+ receive
+ prompted -> error(prompted_without_auth_inline)
+ after 0 -> ok
+ end,
+ ok.
+
+with_repo_device_auth_sets_repo_key_test(_Config) ->
+ %% Authenticating inline from a repository request must retry with
+ %% repository auth: hex_repo only reads repo_key, so an api_key-shaped retry
+ %% goes out with no authorization header at all.
+ TokenStore = ets:new(token_store, [public, set]),
+ true = ets:insert(TokenStore, {oauth_tokens, error}),
+
+ Config = config_with_callbacks(#{
+ get_oauth_tokens => fun() ->
+ [{oauth_tokens, Tokens}] = ets:lookup(TokenStore, oauth_tokens),
+ Tokens
+ end,
+ should_authenticate => fun(no_credentials) -> true end,
+ persist_oauth_tokens => fun(global, Access, Refresh, Expires) ->
+ ets:insert(
+ TokenStore,
+ {oauth_tokens,
+ {ok, #{
+ access_token => Access,
+ refresh_token => Refresh,
+ expires_at => Expires
+ }}}
+ ),
+ ok
+ end
+ }),
+
+ queue_device_response(<<"device_token">>),
+
+ Fun = fun(Cfg) ->
+ case maps:get(repo_key, Cfg, undefined) of
+ undefined -> {ok, {401, #{}, <<"">>}};
+ RepoKey -> RepoKey
+ end
+ end,
+
+ Result = hex_cli_auth:with_repo(
+ Config#{trusted => true},
+ Fun,
+ [{auth_inline, true}, {oauth_open_browser, false}]
+ ),
+ ?assertEqual(<<"Bearer device_token">>, Result),
+
+ ets:delete(TokenStore),
+ ok.
+
+with_repo_token_expired_refresh_test(_Config) ->
+ %% A repository token the server rejects as expired is refreshed and the
+ %% request runs again, rather than the 401 reaching the caller.
+ Now = erlang:system_time(second),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"stale_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now + 3600
+ }}
+ }),
+
+ queue_refresh_response(#{<<"access_token">> => <<"renewed_token">>}),
+
+ Fun = fun(Cfg) ->
+ case maps:get(repo_key, Cfg) of
+ <<"Bearer stale_token">> -> token_expired_response();
+ RepoKey -> RepoKey
+ end
+ end,
+
+ Result = hex_cli_auth:with_repo(Config#{trusted => true}, Fun),
+ ?assertEqual(<<"Bearer renewed_token">>, Result),
+ ok.
+
+with_repo_token_expired_exchange_test(_Config) ->
+ %% Same for a per-repo token: it is exchanged again from the auth_key it
+ %% came from, even though its stored expiry has not passed.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ auth_config => #{
+ <<"hexpm">> => #{
+ auth_key => <<"repo_auth_key">>,
+ oauth_token => #{
+ access_token => <<"stale_repo_token">>,
+ expires_at => Now + 3600
+ }
+ }
+ },
+ persist_oauth_tokens => fun(Scope, Access, Refresh, Expires) ->
+ Self ! {persisted, Scope, Access, Refresh, Expires},
+ ok
+ end
+ }),
+
+ Fun = fun(Cfg) ->
+ case maps:get(repo_key, Cfg) of
+ <<"Bearer stale_repo_token">> -> token_expired_response();
+ RepoKey -> RepoKey
+ end
+ end,
+
+ Result = hex_cli_auth:with_repo(Config#{trusted => true, oauth_exchange => true}, Fun),
+ ?assertMatch(<<"Bearer ", _/binary>>, Result),
+ ?assertNotEqual(<<"Bearer stale_repo_token">>, Result),
+
+ receive
+ {persisted, <<"hexpm">>, _Access, RefreshToken, _Expires} ->
+ ?assertEqual(undefined, RefreshToken)
+ after 100 ->
+ error(token_not_exchanged)
+ end,
+ ok.
+
+refresh_transport_error_keeps_token_test(_Config) ->
+ %% A refresh that never reached the server says nothing about the stored
+ %% token, so it is kept and the caller is told the difference.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now - 100
+ }},
+ clear_oauth_tokens => fun() ->
+ Self ! cleared,
+ ok
+ end
+ }),
+
+ Self ! {hex_http_test, oauth_refresh_response, {error, timeout}},
+
+ ?assertEqual(
+ {error, {auth_error, token_refresh_unavailable}},
+ hex_cli_auth:resolve_api_auth(read, Config)
+ ),
+
+ receive
+ cleared -> error(token_cleared_on_transport_error)
+ after 0 -> ok
+ end,
+ ok.
+
+refresh_server_error_keeps_token_test(_Config) ->
+ %% A 429 or a 5xx is the server having a bad minute, not a refusal of the
+ %% refresh token, so the stored token survives it.
+ Statuses = [429, 500, 502, 503],
+
+ [
+ begin
+ Self = self(),
+ Config = refresh_failure_config(Self),
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_refresh_response,
+ {ok, {Status, Headers, term_to_binary(#{<<"error">> => <<"server_error">>})}}},
+
+ ?assertEqual(
+ {error, {auth_error, token_refresh_unavailable}},
+ hex_cli_auth:resolve_api_auth(read, Config)
+ ),
+
+ receive
+ cleared -> error({token_cleared_on_server_error, Status})
+ after 0 -> ok
+ end
+ end
+ || Status <- Statuses
+ ],
+ ok.
+
+refresh_malformed_body_keeps_token_test(_Config) ->
+ %% A 200 whose body is not a token response says nothing about the refresh
+ %% token either, so it is not read as the server refusing it.
+ Bodies = [
+ <<"not a map">>,
+ #{<<"expires_in">> => 3600},
+ #{<<"access_token">> => <<"new_access_token">>},
+ #{<<"access_token">> => <<"new_access_token">>, <<"expires_in">> => <<"3600">>},
+ #{<<"access_token">> => 42, <<"expires_in">> => 3600}
+ ],
+
+ [
+ begin
+ Self = self(),
+ Config = refresh_failure_config(Self),
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_refresh_response, {ok, {200, Headers, term_to_binary(Body)}}},
+
+ ?assertEqual(
+ {error, {auth_error, token_refresh_unavailable}},
+ hex_cli_auth:resolve_api_auth(read, Config)
+ ),
+
+ receive
+ cleared -> error({token_cleared_on_malformed_body, Body})
+ after 0 -> ok
+ end
+ end
+ || Body <- Bodies
+ ],
+ ok.
+
+refresh_refusal_clears_token_test(_Config) ->
+ %% A 400 or a 401 is the server refusing the refresh token itself: it will
+ %% not work again, so the stored token is dropped.
+ Statuses = [400, 401],
+
+ [
+ begin
+ Self = self(),
+ Config = refresh_failure_config(Self),
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ Self !
+ {hex_http_test, oauth_refresh_response,
+ {ok, {Status, Headers, term_to_binary(#{<<"error">> => <<"invalid_grant">>})}}},
+
+ ?assertEqual(
+ {error, {auth_error, token_refresh_failed}},
+ hex_cli_auth:resolve_api_auth(read, Config)
+ ),
+
+ receive
+ cleared -> ok
+ after 100 -> error({token_not_cleared, Status})
+ end
+ end
+ || Status <- Statuses
+ ],
+ ok.
+
%%====================================================================
%% Test Cases - Concurrency
%%====================================================================
@@ -1198,10 +1601,253 @@ device_auth_concurrent_serialized_reuses_login_test(_Config) ->
ets:delete(TokenStore),
ok.
+device_auth_lock_released_before_request_test(_Config) ->
+ %% The device auth lock covers acquiring the credential, not running the
+ %% request. A request answering 401 comes back to the same lock, and
+ %% global:trans/4 on a lock this process already holds does not nest: the
+ %% inner transaction releases it while the outer one is still running.
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens => error,
+ should_authenticate => fun(no_credentials) ->
+ Self ! {locked_during_prompt, device_auth_lock_held()},
+ true
+ end
+ }),
+
+ queue_device_response(<<"device_token">>),
+
+ Result = hex_cli_auth:with_api(
+ write,
+ Config,
+ fun(Cfg) ->
+ Self ! {locked_during_request, device_auth_lock_held()},
+ maps:get(api_key, Cfg)
+ end,
+ [{oauth_open_browser, false}]
+ ),
+ ?assertEqual(<<"Bearer device_token">>, Result),
+
+ receive
+ {locked_during_prompt, LockedDuringPrompt} ->
+ ?assertEqual(true, LockedDuringPrompt)
+ after 100 ->
+ error(should_authenticate_not_called)
+ end,
+
+ receive
+ {locked_during_request, LockedDuringRequest} ->
+ ?assertEqual(false, LockedDuringRequest)
+ after 100 ->
+ error(request_not_run)
+ end,
+ ok.
+
%%====================================================================
%% Helper Functions
%%====================================================================
+sso_reauth_reported_on_refresh_test(_Config) ->
+ %% The organizations the server flags on a refresh reach the build tool.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now - 100
+ }},
+ sso_reauth => fun(Organizations) ->
+ Self ! {sso_reauth, Organizations},
+ ok
+ end
+ }),
+
+ queue_refresh_response(#{<<"sso_reauth_required">> => [<<"acme">>]}),
+
+ {ok, _ApiKey, _AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
+
+ receive
+ {sso_reauth, Organizations} -> ?assertEqual([<<"acme">>], Organizations)
+ after 100 ->
+ error(sso_reauth_not_called)
+ end,
+ ok.
+
+sso_reauth_reported_empty_test(_Config) ->
+ %% A server that says nothing means nothing is lapsed, and the build tool
+ %% is told so rather than left holding a stale set.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now - 100
+ }},
+ sso_reauth => fun(Organizations) ->
+ Self ! {sso_reauth, Organizations},
+ ok
+ end
+ }),
+
+ {ok, _ApiKey, _AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
+
+ receive
+ {sso_reauth, Organizations} -> ?assertEqual([], Organizations)
+ after 100 ->
+ error(sso_reauth_not_called)
+ end,
+ ok.
+
+sso_reauth_malformed_not_reported_test(_Config) ->
+ %% A set the server sent in a shape we cannot read is not reported at all.
+ %% The build tool takes the empty list for "nothing lapsed" and deletes the
+ %% organizations it holds, which drops the prompt the user needs.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now - 100
+ }},
+ sso_reauth => fun(Organizations) ->
+ Self ! {sso_reauth, Organizations},
+ ok
+ end
+ }),
+
+ queue_refresh_response(#{<<"sso_reauth_required">> => <<"acme">>}),
+
+ {ok, _ApiKey, _AuthContext} = hex_cli_auth:resolve_api_auth(read, Config),
+
+ receive
+ {sso_reauth, Organizations} -> error({sso_reauth_reported, Organizations})
+ after 0 -> ok
+ end,
+ ok.
+
+refresh_tokens_forces_a_refresh_test(_Config) ->
+ %% A token that has not expired is still refreshed: what it carries can
+ %% change without its lifetime running out.
+ Now = erlang:system_time(second),
+ Self = self(),
+ Config = config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"valid_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now + 3600
+ }},
+ persist_oauth_tokens => fun(Scope, Access, Refresh, Expires) ->
+ Self ! {persisted, Scope, Access, Refresh, Expires},
+ ok
+ end
+ }),
+
+ queue_refresh_response(#{<<"access_token">> => <<"renewed_token">>}),
+
+ ?assertEqual(ok, hex_cli_auth:refresh_tokens(Config)),
+
+ receive
+ {persisted, global, Access, _Refresh, _Expires} ->
+ ?assertEqual(<<"renewed_token">>, Access)
+ after 100 ->
+ error(token_not_persisted)
+ end,
+ ok.
+
+refresh_tokens_without_credentials_test(_Config) ->
+ Config = config_with_callbacks(#{}),
+
+ ?assertEqual(
+ {error, {auth_error, no_credentials}},
+ hex_cli_auth:refresh_tokens(Config)
+ ),
+ ok.
+
+%% @private
+%% Plants the next refresh response the test HTTP adapter will hand back,
+%% merged over a working one so a test only states what it cares about.
+queue_refresh_response(Overrides) ->
+ Payload = maps:merge(
+ #{
+ <<"access_token">> => <<"new_access_token">>,
+ <<"refresh_token">> => <<"new_refresh_token">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600
+ },
+ Overrides
+ ),
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ self() !
+ {hex_http_test, oauth_refresh_response, {ok, {200, Headers, term_to_binary(Payload)}}},
+ ok.
+
+%% @private
+%% Plants the token the next device auth poll hands back.
+queue_device_response(AccessToken) ->
+ Payload = #{
+ <<"access_token">> => AccessToken,
+ <<"refresh_token">> => <<"device_refresh">>,
+ <<"token_type">> => <<"Bearer">>,
+ <<"expires_in">> => 3600
+ },
+ Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
+ self() !
+ {hex_http_test, oauth_device_response, {ok, {200, Headers, term_to_binary(Payload)}}},
+ ok.
+
+%% @private
+%% The 401 hexpm answers a request whose token it considers expired.
+token_expired_response() ->
+ Headers = #{<<"www-authenticate">> => <<"Bearer realm=\"hex\", error=\"token_expired\"">>},
+ {ok, {401, Headers, <<"">>}}.
+
+%% @private
+%% An expired global token whose refresh is about to fail, with the clear
+%% callback reporting to Pid so a test can say whether the token was dropped.
+refresh_failure_config(Pid) ->
+ Now = erlang:system_time(second),
+ config_with_callbacks(#{
+ oauth_tokens =>
+ {ok, #{
+ access_token => <<"expired_token">>,
+ refresh_token => <<"refresh_token">>,
+ expires_at => Now - 100
+ }},
+ clear_oauth_tokens => fun() ->
+ Pid ! cleared,
+ ok
+ end
+ }).
+
+%% @private
+%% Whether the device auth lock is held by anyone other than the process asking.
+%% A concurrent caller arrives with its own pid as the lock requester id, which
+%% is what makes global refuse it while another process holds the lock.
+device_auth_lock_held() ->
+ Parent = self(),
+ spawn(fun() ->
+ Id = {{hex_cli_auth, device_auth}, self()},
+ case global:set_lock(Id, [node()], 0) of
+ true ->
+ Parent ! {device_auth_lock, false},
+ global:del_lock(Id, [node()]);
+ false ->
+ Parent ! {device_auth_lock, true}
+ end
+ end),
+ receive
+ {device_auth_lock, Held} -> Held
+ after 5000 ->
+ error(device_auth_lock_probe_timed_out)
+ end.
+
config_with_callbacks(Opts) ->
?CONFIG#{cli_auth_callbacks => make_callbacks(Opts)}.
@@ -1211,6 +1857,7 @@ make_callbacks(Opts) ->
ShouldAuthenticate = maps:get(should_authenticate, Opts, fun(_) -> false end),
PersistFn = maps:get(persist_oauth_tokens, Opts, fun(_, _, _, _) -> ok end),
ClearFn = maps:get(clear_oauth_tokens, Opts, fun() -> ok end),
+ SsoReauthFn = maps:get(sso_reauth, Opts, fun(_Organizations) -> ok end),
DefaultGetOAuthTokens = fun() -> maps:get(oauth_tokens, Opts, error) end,
GetOAuthTokensFn = maps:get(get_oauth_tokens, Opts, DefaultGetOAuthTokens),
@@ -1219,6 +1866,7 @@ make_callbacks(Opts) ->
get_oauth_tokens => GetOAuthTokensFn,
persist_oauth_tokens => PersistFn,
clear_oauth_tokens => ClearFn,
+ sso_reauth => SsoReauthFn,
prompt_otp => PromptOtp,
should_authenticate => ShouldAuthenticate,
get_client_id => fun() -> <<"test_client">> end
diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl
index 56b134a..990ee93 100644
--- a/test/support/hex_http_test.erl
+++ b/test/support/hex_http_test.erl
@@ -33,6 +33,22 @@ api_headers() ->
<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>
}.
+device_authorization_fixture() ->
+ DeviceCode = base64:encode(crypto:strong_rand_bytes(32)),
+ UserCode = iolist_to_binary([
+ integer_to_binary(rand:uniform(9999)), "-",
+ integer_to_binary(rand:uniform(9999))
+ ]),
+ Payload = #{
+ <<"device_code">> => DeviceCode,
+ <<"user_code">> => UserCode,
+ <<"verification_uri">> => <<"https://hex.pm/oauth/device">>,
+ <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=", UserCode/binary>>,
+ <<"expires_in">> => 600,
+ <<"interval">> => 0
+ },
+ {ok, {200, api_headers(), term_to_binary(Payload)}}.
+
fixture(get, _, #{<<"if-none-match">> := <<"\"dummy\"">> = ETag}, _) ->
Headers = #{
<<"etag">> => ETag
@@ -346,20 +362,12 @@ fixture(post, <>, _, {_, Body}) ->
fixture(post, <>, _, {_, Body}) ->
DecodedBody = binary_to_term(Body),
#{<<"client_id">> := _ClientId, <<"scope">> := _Scope} = DecodedBody,
- DeviceCode = base64:encode(crypto:strong_rand_bytes(32)),
- UserCode = iolist_to_binary([
- integer_to_binary(rand:uniform(9999)), "-",
- integer_to_binary(rand:uniform(9999))
- ]),
- Payload = #{
- <<"device_code">> => DeviceCode,
- <<"user_code">> => UserCode,
- <<"verification_uri">> => <<"https://hex.pm/oauth/device">>,
- <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=", UserCode/binary>>,
- <<"expires_in">> => 600,
- <<"interval">> => 0
- },
- {ok, {200, api_headers(), term_to_binary(Payload)}};
+ receive
+ {hex_http_test, oauth_device_authorization_response, Response} ->
+ Response
+ after 0 ->
+ device_authorization_fixture()
+ end;
fixture(post, <>, _, {_, Body}) ->
DecodedBody = binary_to_term(Body),
@@ -420,6 +428,15 @@ fixture(post, <>, _, {_, Body}) ->
{ok, {400, api_headers(), term_to_binary(ErrorPayload)}}
end;
+fixture(post, <>, _, {_, Body}) ->
+ #{<<"organizations">> := Organizations} = binary_to_term(Body),
+ Joined = iolist_to_binary(lists:join(<<"-">>, Organizations)),
+ Payload = #{
+ <<"verification_uri">> => <<"https://hex.pm/sso/authorize/", Joined/binary>>,
+ <<"expires_in">> => 600
+ },
+ {ok, {201, api_headers(), term_to_binary(Payload)}};
+
fixture(post, <>, _, _) ->
% OAuth revoke always returns 200 OK per RFC 7009
{ok, {200, api_headers(), term_to_binary(nil)}};