Skip to content

Commit c7cbc92

Browse files
committed
Close what the second CLI review found
A repository request that triggered inline device auth retried with the API key set, and repository requests read repo_key, so the retry went out unauthenticated and 401ed again. It re-resolves repository auth after authenticating. with_repo also executed once and never acted on a 401 from a token it had already resolved, so a token the server rejects as expired was never refreshed or re-exchanged; it retries once with renewed credentials. A transport failure during a refresh was reported as an expired session and dropped the stored token, so a DNS blip printed that the session had expired and downgraded the rest of the run to anonymous. Only a refusal from the server clears the token now. with_repo documented auth_inline as defaulting to false and then took true on the 401 path, so a private package could open an interactive device flow against the caller's stated default. One transport error while polling ended a device authorization the user was minutes into. Polling continues until the device code expires. A malformed verification URI crashed the CLI, because the URL check threw where its caller expected a return value. The refresh token sentinel had two owners: an absent one was undefined in one place and absent in another, and undefined reached persistence, where Elixir stored it as a refresh token. The grant is normalized once. has_refresh_token was derived three ways in one module, and the source member of the auth context was written on eight paths and read nowhere.
1 parent eb5508a commit c7cbc92

5 files changed

Lines changed: 528 additions & 137 deletions

File tree

src/hex_api_oauth.erl

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
-type oauth_tokens() :: #{
2121
access_token := binary(),
22-
refresh_token => binary() | undefined,
22+
refresh_token => binary(),
2323
expires_at := integer(),
2424
%% Organizations the session must authenticate against their identity
2525
%% provider for. Their scopes are not in this token and re-requesting them
@@ -182,14 +182,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
182182
<<"access_token">> := AccessToken,
183183
<<"expires_in">> := ExpiresIn
184184
} = TokenResponse,
185-
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
186-
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
187-
{ok, #{
185+
Tokens = #{
188186
access_token => AccessToken,
189-
refresh_token => RefreshToken,
190-
expires_at => TokenExpiresAt,
187+
expires_at => erlang:system_time(second) + ExpiresIn,
191188
sso_reauth_required => sso_reauth_required(TokenResponse)
192-
}};
189+
},
190+
{ok, put_refresh_token(Tokens, TokenResponse)};
193191
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
194192
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
195193
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
@@ -203,8 +201,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
203201
{error, {access_denied, Status, Body}};
204202
{ok, {Status, _, Body}} ->
205203
{error, {poll_failed, Status, Body}};
206-
{error, Reason} ->
207-
{error, Reason}
204+
{error, _Reason} ->
205+
%% A request that did not get through says nothing about the
206+
%% authorization, which the user may be minutes into. The
207+
%% device code lives on the server until it expires, so keep
208+
%% polling until then.
209+
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
208210
end
209211
end.
210212

@@ -391,19 +393,27 @@ sso_reauth_required(TokenResponse) ->
391393
%% Opens a URL in the default browser using the platform's opener: `open' on
392394
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
393395
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
394-
%% case on a headless machine.
395-
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
396+
%% case on a headless machine, and `{error, invalid_url}' for anything that is
397+
%% not an http(s) URL.
398+
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
396399
open_browser(Url) when is_binary(Url) ->
397-
ok = ensure_valid_http_url(Url),
398-
UrlStr = binary_to_list(Url),
400+
case valid_http_url(Url) of
401+
true ->
402+
spawn_browser(binary_to_list(Url));
403+
false ->
404+
{error, invalid_url}
405+
end.
406+
407+
%% @private
408+
spawn_browser(Url) ->
399409
{Cmd, Args} =
400410
case os:type() of
401411
{unix, darwin} ->
402-
{"open", [UrlStr]};
412+
{"open", [Url]};
403413
{unix, _} ->
404-
{"xdg-open", [UrlStr]};
414+
{"xdg-open", [Url]};
405415
{win32, _} ->
406-
{"cmd", ["/c", "start", "", UrlStr]}
416+
{"cmd", ["/c", "start", "", Url]}
407417
end,
408418
case os:find_executable(Cmd) of
409419
false ->
@@ -414,15 +424,23 @@ open_browser(Url) when is_binary(Url) ->
414424
end.
415425

416426
%% @private
417-
%% Validates that a URL uses http:// or https:// scheme.
418-
-spec ensure_valid_http_url(binary()) -> ok.
419-
ensure_valid_http_url(Url) when is_binary(Url) ->
427+
%% Whether a URL uses the http:// or https:// scheme.
428+
-spec valid_http_url(binary()) -> boolean().
429+
valid_http_url(Url) when is_binary(Url) ->
420430
case uri_string:parse(Url) of
421-
#{scheme := <<"https">>} -> ok;
422-
#{scheme := <<"http">>} -> ok;
423-
_ -> throw({invalid_url, Url})
431+
#{scheme := <<"https">>} -> true;
432+
#{scheme := <<"http">>} -> true;
433+
_ -> false
424434
end.
425435

436+
%% @private
437+
%% A response without a refresh token carries no key at all rather than a
438+
%% placeholder, so what a build tool stores is only ever a real token.
439+
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
440+
Tokens#{refresh_token => RefreshToken};
441+
put_refresh_token(Tokens, _TokenResponse) ->
442+
Tokens.
443+
426444
%% @private
427445
%% Get the hostname of the current machine.
428446
-spec get_hostname() -> binary().

0 commit comments

Comments
 (0)