Skip to content

Commit a0ab6bc

Browse files
authored
Clear the OAuth token and warn when its refresh fails (#1184)
An expired global OAuth token whose refresh the server rejects was refreshed once per package during dependency resolution -- dozens of failed POSTs to /oauth/token, serialized by the token-refresh lock -- and the failure was swallowed, so resolution silently continued without credentials. This regressed the OnceCache memoization and warning dropped in cda8717. Implement the new hex_cli_auth clear_oauth_tokens callback: drop the token from in-memory state (keeping the on-disk token so a transient failure can recover on the next run) and warn once that the session expired and to re-run `mix hex.user auth`. With the token gone, the rest of the run stops retrying the refresh. Re-vendors hex_core.
1 parent 16809f6 commit a0ab6bc

4 files changed

Lines changed: 92 additions & 2 deletions

File tree

lib/hex/auth.ex

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ defmodule Hex.Auth do
4040
get_auth_config: &get_auth_config/1,
4141
get_oauth_tokens: &get_oauth_tokens/0,
4242
persist_oauth_tokens: &persist_oauth_tokens/4,
43+
clear_oauth_tokens: &clear_oauth_tokens/0,
4344
prompt_otp: &prompt_otp/1,
4445
get_client_id: &Hex.API.OAuth.client_id/0,
4546
should_authenticate: &should_authenticate/1
@@ -111,6 +112,22 @@ defmodule Hex.Auth do
111112
:ok
112113
end
113114

115+
# Invoked by hex_cli_auth when the stored global OAuth token is expired and
116+
# could not be refreshed. Drop it from in-memory state only — keeping the
117+
# on-disk token, since a refresh can fail transiently — so the rest of this
118+
# run stops retrying the doomed refresh and proceeds unauthenticated.
119+
defp clear_oauth_tokens do
120+
Hex.State.put(:oauth_token, nil)
121+
122+
Hex.Shell.warn(
123+
"Your authentication session has expired and could not be refreshed. " <>
124+
"Continuing without credentials; requests for private resources will fail or " <>
125+
"prompt for authentication. Run `mix hex.user auth` to re-authenticate"
126+
)
127+
128+
:ok
129+
end
130+
114131
defp prompt_otp(message) do
115132
case Hex.Shell.prompt(message) do
116133
nil ->

src/mix_hex_cli_auth.erl

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
%% == Callbacks ==
1111
%%
1212
%% Callbacks are provided via the `cli_auth_callbacks' key in the config map.
13-
%% All callbacks are required:
13+
%% All callbacks below are required unless marked optional:
1414
%%
1515
%% ```
1616
%% #{
@@ -30,6 +30,13 @@
3030
%% RefreshToken :: binary() | undefined,
3131
%% ExpiresAt :: integer()) -> ok),
3232
%%
33+
%% %% Invalidate the stored global OAuth token after it expired and could
34+
%% %% not be refreshed (optional). Lets the build tool drop the unusable
35+
%% %% token so concurrent and subsequent callers stop retrying the doomed
36+
%% %% refresh, and warn the user. Invoked at most once per resolution, while
37+
%% %% holding the token-refresh lock.
38+
%% clear_oauth_tokens => fun(() -> ok),
39+
%%
3340
%% %% User interaction
3441
%% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
3542
%% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()),
@@ -114,6 +121,7 @@
114121
ExpiresAt :: integer()
115122
) -> ok
116123
),
124+
clear_oauth_tokens => fun(() -> ok),
117125
prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
118126
should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()),
119127
get_client_id := fun(() -> binary())
@@ -602,7 +610,7 @@ do_resolve_oauth_token_with_context(Config) ->
602610
is_binary(maps:get(refresh_token, Tokens)),
603611
case is_token_expired(ExpiresAt) of
604612
true ->
605-
maybe_refresh_token_with_context(Config, Tokens);
613+
refresh_or_clear(Config, Tokens);
606614
false ->
607615
BearerToken = <<"Bearer ", AccessToken/binary>>,
608616
{ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}}
@@ -611,6 +619,21 @@ do_resolve_oauth_token_with_context(Config) ->
611619
{error, no_auth}
612620
end.
613621

622+
%% @private
623+
%% Refresh an expired global token; if the refresh fails, invalidate the stored
624+
%% token via the optional clear_oauth_tokens callback. This runs inside the
625+
%% token_refresh lock, so the unusable token is dropped exactly once and the
626+
%% callers serialized behind the lock re-read it as absent instead of each
627+
%% retrying the doomed refresh against the server.
628+
refresh_or_clear(Config, Tokens) ->
629+
case maybe_refresh_token_with_context(Config, Tokens) of
630+
{ok, _Bearer, _Ctx} = Ok ->
631+
Ok;
632+
{error, _} = Error ->
633+
maybe_call_callback(Config, clear_oauth_tokens, []),
634+
Error
635+
end.
636+
614637
%% @private
615638
maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when
616639
is_binary(RefreshToken)
@@ -758,3 +781,13 @@ call_callback(Config, Name, Args) ->
758781
#{cli_auth_callbacks := Callbacks} = Config,
759782
Fun = maps:get(Name, Callbacks),
760783
erlang:apply(Fun, Args).
784+
785+
%% @private
786+
%% Like call_callback/3 but for optional callbacks: returns ok without doing
787+
%% anything when the callback is not provided.
788+
maybe_call_callback(Config, Name, Args) ->
789+
#{cli_auth_callbacks := Callbacks} = Config,
790+
case maps:find(Name, Callbacks) of
791+
{ok, Fun} -> erlang:apply(Fun, Args);
792+
error -> ok
793+
end.

test/hex/auth_test.exs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,39 @@ defmodule Hex.AuthTest do
5151
assert get_auth_config("does-not-exist") == :undefined
5252
end
5353
end
54+
55+
describe "clear_oauth_tokens/0 callback" do
56+
test "drops the in-memory token and warns to re-authenticate" do
57+
Hex.State.put(:oauth_token, %{
58+
access_token: "expired",
59+
refresh_token: "refresh",
60+
expires_at: System.system_time(:second) - 100
61+
})
62+
63+
assert Hex.Auth.callbacks().clear_oauth_tokens.() == :ok
64+
65+
assert Hex.State.get(:oauth_token) == nil
66+
67+
output = Case.shell_output()
68+
assert output =~ "could not be refreshed"
69+
assert output =~ "mix hex.user auth"
70+
end
71+
72+
test "keeps the on-disk token so a later run can retry the refresh" do
73+
in_tmp("clear_oauth_tokens", fn ->
74+
set_home_cwd()
75+
76+
Hex.OAuth.store_token(%{
77+
access_token: "expired",
78+
refresh_token: "refresh",
79+
expires_at: System.system_time(:second) - 100
80+
})
81+
82+
assert Hex.Auth.callbacks().clear_oauth_tokens.() == :ok
83+
84+
assert Hex.State.get(:oauth_token) == nil
85+
assert Hex.Config.read()[:"$oauth_token"] != nil
86+
end)
87+
end
88+
end
5489
end

test/hex/remote_converger_test.exs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ defmodule Hex.RemoteConvergerTest do
133133
end)
134134

135135
refute_received {:mix_shell, :yes?, _}
136+
137+
# The unusable session is dropped after the first failed refresh, so the
138+
# rest of the resolution falls back to unauthenticated fetches instead of
139+
# retrying the doomed refresh for every package.
140+
assert Hex.State.get(:oauth_token) == nil
136141
end)
137142
end
138143

0 commit comments

Comments
 (0)