diff --git a/hex-2.5.1.tar.gz b/hex-2.5.1.tar.gz new file mode 100644 index 00000000..0604c52f Binary files /dev/null and b/hex-2.5.1.tar.gz differ diff --git a/lib/hex/api/oauth.ex b/lib/hex/api/oauth.ex index be7534f8..4a54a8b7 100644 --- a/lib/hex/api/oauth.ex +++ b/lib/hex/api/oauth.ex @@ -41,6 +41,31 @@ defmodule Hex.API.OAuth do defp drop_undefined_refresh_token(tokens), do: tokens + @doc """ + Requests a URL for authenticating this session against organizations that + require single sign-on. + + ## Examples + + iex> Hex.API.OAuth.sso_authorization(["acme"]) + {:ok, {201, _headers, %{"verification_uri" => "https://hex.pm/sso/authorize/...", + "expires_in" => 600}}} + """ + def sso_authorization(organizations) do + config = Client.config() + + Hex.Auth.with_api(:read, config, fn config -> + :mix_hex_api_oauth.sso_authorization(config, Enum.map(organizations, &to_string/1)) + end) + end + + @doc """ + Opens a URL in the default browser. + """ + def open_browser(url) do + :mix_hex_api_oauth.open_browser(url) + end + @doc """ Revokes an OAuth token (access or refresh token). diff --git a/lib/hex/auth.ex b/lib/hex/auth.ex index 2eab34f8..d535ed7a 100644 --- a/lib/hex/auth.ex +++ b/lib/hex/auth.ex @@ -25,6 +25,17 @@ defmodule Hex.Auth do end end + @doc """ + Refresh the stored OAuth token now, whether or not it has expired. + + Authenticating a session against an organization's identity provider grants + scopes the current access token was minted without, and this is how they are + picked up without waiting the token out. + """ + def refresh_tokens(config) do + :mix_hex_cli_auth.refresh_tokens(config) + end + @doc """ Execute a function with preemptive authentication using the provided auth data. """ @@ -41,6 +52,7 @@ defmodule Hex.Auth do get_oauth_tokens: &get_oauth_tokens/0, persist_oauth_tokens: &persist_oauth_tokens/4, clear_oauth_tokens: &clear_oauth_tokens/0, + sso_reauth: &sso_reauth/1, prompt_otp: &prompt_otp/1, get_client_id: &Hex.API.OAuth.client_id/0, should_authenticate: &should_authenticate/1 @@ -128,6 +140,26 @@ defmodule Hex.Auth do :ok end + # Invoked by hex_cli_auth after every token grant with the organizations the + # server says this session has to authenticate through their identity + # provider for. Store them with the token rather than acting on them: which + # ones matter depends on what the running command needs, and a later run that + # reuses this token without refreshing it would otherwise have no idea. + defp sso_reauth(organizations) do + token_data = Hex.State.get(:oauth_token) + + if is_map(token_data) and Hex.OAuth.sso_reauth_required() != organizations do + Hex.OAuth.store_token(put_sso_reauth(token_data, organizations)) + end + + :ok + end + + defp put_sso_reauth(token_data, []), do: Map.delete(token_data, :sso_reauth_required) + + defp put_sso_reauth(token_data, organizations), + do: Map.put(token_data, :sso_reauth_required, organizations) + defp prompt_otp(message) do case Hex.Shell.prompt(message) do nil -> diff --git a/lib/hex/oauth.ex b/lib/hex/oauth.ex index 77742b65..21dddba3 100644 --- a/lib/hex/oauth.ex +++ b/lib/hex/oauth.ex @@ -31,6 +31,17 @@ defmodule Hex.OAuth do Hex.State.put(:oauth_token, token_data) end + @doc """ + The organizations the stored session has to authenticate through their + identity provider for before it can reach them again. + """ + def sso_reauth_required do + case Hex.State.get(:oauth_token) do + %{sso_reauth_required: organizations} when is_list(organizations) -> organizations + _token_data -> [] + end + end + @doc """ Clears all stored OAuth tokens. """ diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 3741ca67..7cd067d7 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -62,6 +62,7 @@ defmodule Hex.RemoteConverger do |> verify_prefetches() check_and_refresh_auth(prefetches) + check_sso_reauth(prefetches) Registry.prefetch(prefetches) locked = prepare_locked(lock, old_lock, deps) @@ -937,6 +938,114 @@ defmodule Hex.RemoteConverger do end end + # The organizations a resolution can need are exactly the ones its own + # dependencies name: a published package's dependencies come from the public + # repository or from its own organization, so nothing private turns up part + # way through. That is what makes one prompt for the batch possible rather + # than a 403 at a time, and it is why a member of ten SSO organizations who + # depends on two is asked about two. + @doc false + def check_sso_reauth(prefetches) do + needed = + prefetches + |> Enum.flat_map(fn + {"hexpm:" <> organization = repo, _package} -> + # An organization authenticated with its own key does not touch the + # stored token, so nothing about it is worth asking. + if repo_requires_user_oauth?(repo), do: [organization], else: [] + + {_repo, _package} -> + [] + end) + |> MapSet.new() + + Hex.OAuth.sso_reauth_required() + |> Enum.filter(&MapSet.member?(needed, &1)) + |> prompt_sso_reauth() + end + + defp prompt_sso_reauth([]), do: :ok + + defp prompt_sso_reauth(organizations) do + cond do + Hex.State.fetch!(:offline) -> + unavailable(organizations, "Hex is offline") + + Hex.State.get(:api_key) -> + unavailable(organizations, "HEX_API_KEY authenticates as itself") + + Hex.Shell.yes?("#{sso_subject(organizations)} SSO authentication. Authenticate now?") -> + start_sso_reauth(organizations) + + true -> + Hex.Shell.warn("Packages from #{names(organizations)} will not be available.") + end + end + + defp unavailable(organizations, reason) do + Hex.Shell.warn( + "#{sso_subject(organizations)} SSO authentication, but #{reason}. " <> + "Packages from #{names(organizations)} will not be available." + ) + end + + defp start_sso_reauth(organizations) do + case Hex.API.OAuth.sso_authorization(organizations) do + {:ok, {status, _headers, %{"verification_uri" => uri}}} + when status in 200..299 and is_binary(uri) -> + # The URL goes in the prompt rather than beside it: `mix deps.get + # --quiet` swallows info output, and asking someone to finish something + # in a browser without telling them where is a dead end. + open_browser(uri) + Hex.Shell.prompt("Open #{uri} to authenticate, then press enter") + finish_sso_reauth(organizations) + + {:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) -> + Hex.Shell.warn("Could not start SSO authentication: #{message}") + + _other -> + Hex.Shell.warn("Could not start SSO authentication.") + end + end + + # Opening a browser is a convenience on top of the printed URL, so nothing it + # does is worth ending a resolution over. + defp open_browser(uri) do + case URI.parse(uri) do + %URI{scheme: scheme} when scheme in ["http", "https"] -> + try do + Hex.API.OAuth.open_browser(uri) + catch + _kind, _reason -> :ok + end + + _other -> + :ok + end + end + + # The session and its refresh token are untouched by all this; what changed is + # what the session may reach, so a refresh is what picks it up. + defp finish_sso_reauth(organizations) do + config = Hex.API.Client.config([]) + + with :ok <- Hex.Auth.refresh_tokens(config), + [] <- Enum.filter(Hex.OAuth.sso_reauth_required(), &(&1 in organizations)) do + :ok + else + _other -> + Hex.Shell.warn( + "#{sso_subject(organizations)} SSO authentication. " <> + "Packages from #{names(organizations)} will not be available." + ) + end + end + + defp sso_subject([organization]), do: "#{organization} requires" + defp sso_subject(organizations), do: "#{names(organizations)} require" + + defp names(organizations), do: Enum.join(organizations, ", ") + @doc false def auth_preflight_required?(prefetches) do prefetches diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 55f52a05..fcaef639 100644 --- a/src/mix_hex_advisory.erl +++ b/src/mix_hex_advisory.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Display-time deduplication of security advisories. diff --git a/src/mix_hex_api.erl b/src/mix_hex_api.erl index 0c3eefc1..9c4e8d9d 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index fb80b31f..e863d609 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Authentication. diff --git a/src/mix_hex_api_key.erl b/src/mix_hex_api_key.erl index b83a1607..8f396e1b 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Keys. diff --git a/src/mix_hex_api_oauth.erl b/src/mix_hex_api_oauth.erl index fa6a0aa7..6192d17c 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - OAuth. @@ -10,6 +10,8 @@ device_auth_flow/5, poll_device_token/3, refresh_token/3, + sso_authorization/2, + open_browser/1, revoke_token/3, client_credentials_token/4, client_credentials_token/5 @@ -20,7 +22,11 @@ -type oauth_tokens() :: #{ access_token := binary(), refresh_token => binary() | undefined, - expires_at := integer() + 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() :: @@ -183,7 +189,8 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) -> {ok, #{ access_token => AccessToken, refresh_token => RefreshToken, - expires_at => TokenExpiresAt + expires_at => TokenExpiresAt, + sso_reauth_required => sso_reauth_required(TokenResponse) }}; {ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} -> poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt); @@ -262,6 +269,30 @@ refresh_token(Config, ClientId, RefreshToken) -> }, mix_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 = mix_hex_core:default_config(). +%% 2> mix_hex_api_oauth:sso_authorization(Config, [<<"acme">>]). +%% {ok, {201, _, #{ +%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>, +%% <<"expires_in">> => 600 +%% }}} +%% ''' +%% @end +-spec sso_authorization(mix_hex_core:config(), [binary()]) -> mix_hex_api:response(). +sso_authorization(Config, Organizations) -> + Path = <<"oauth/sso_authorization">>, + mix_hex_api:post(Config, Path, #{<<"organizations">> => Organizations}). + %% @doc %% Exchanges an API key for an OAuth access token using the client credentials grant. %% @@ -343,13 +374,13 @@ revoke_token(Config, ClientId, Token) -> }, mix_hex_api:post(Config, Path, Params). -%%==================================================================== -%% Internal functions -%%==================================================================== - -%% @private -%% Open a URL in the default browser. -%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows). +%% @doc +%% Opens a URL in the default browser. +%% +%% Uses 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. +%% @end -spec open_browser(binary()) -> ok | {error, browser_not_found}. open_browser(Url) when is_binary(Url) -> ok = ensure_valid_http_url(Url), @@ -371,6 +402,19 @@ open_browser(Url) when is_binary(Url) -> ok end. +%%==================================================================== +%% Internal functions +%%==================================================================== + +%% @private +%% Older servers do not send the field at all, which means nothing is lapsed. +-spec sso_reauth_required(map()) -> [binary()]. +sso_reauth_required(TokenResponse) -> + case maps:get(<<"sso_reauth_required">>, TokenResponse, []) of + Organizations when is_list(Organizations) -> Organizations; + _Other -> [] + end. + %% @private %% Validates that a URL uses http:// or https:// scheme. -spec ensure_valid_http_url(binary()) -> ok. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index 911e8cea..0f8c5d82 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Organizations. diff --git a/src/mix_hex_api_organization_member.erl b/src/mix_hex_api_organization_member.erl index 87995f63..61448a97 100644 --- a/src/mix_hex_api_organization_member.erl +++ b/src/mix_hex_api_organization_member.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Organization Members. diff --git a/src/mix_hex_api_package.erl b/src/mix_hex_api_package.erl index ebfabcfa..6692aa9e 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Packages. diff --git a/src/mix_hex_api_package_owner.erl b/src/mix_hex_api_package_owner.erl index 3cfc5e82..44daea36 100644 --- a/src/mix_hex_api_package_owner.erl +++ b/src/mix_hex_api_package_owner.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Package Owners. diff --git a/src/mix_hex_api_release.erl b/src/mix_hex_api_release.erl index b01fa8e3..250f6afd 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Releases. diff --git a/src/mix_hex_api_short_url.erl b/src/mix_hex_api_short_url.erl index cec25fe4..00a23c9f 100644 --- a/src/mix_hex_api_short_url.erl +++ b/src/mix_hex_api_short_url.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Short URLs. diff --git a/src/mix_hex_api_user.erl b/src/mix_hex_api_user.erl index b61e00a5..d970a1c9 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex HTTP API - Users. diff --git a/src/mix_hex_cli_auth.erl b/src/mix_hex_cli_auth.erl index 997cb1c9..45614109 100644 --- a/src/mix_hex_cli_auth.erl +++ b/src/mix_hex_cli_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Authentication handling with callback functions for build-tool-specific operations. @@ -37,6 +37,13 @@ %% %% 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, 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()), @@ -89,7 +96,8 @@ with_repo/2, with_repo/3, resolve_api_auth/2, - resolve_repo_auth/1 + resolve_repo_auth/1, + refresh_tokens/1 ]). -export_type([ @@ -122,6 +130,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()) @@ -396,6 +405,32 @@ execute_optional_with_retry(BaseConfig, Fun, Opts) -> Other 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(mix_hex_core:config()) -> ok | {error, auth_error()}. +refresh_tokens(Config) -> + global:trans( + {{?MODULE, token_refresh}, self()}, + fun() -> + case call_callback(Config, get_oauth_tokens, []) of + {ok, Tokens} -> + case maybe_refresh_token_with_context(Config, Tokens) of + {ok, _BearerToken, _AuthContext} -> ok; + {error, _Reason} = Error -> Error + end; + error -> + {error, {auth_error, no_credentials}} + end + end, + [node()], + infinity + ). + %%==================================================================== %% Internal functions - Device Auth %%==================================================================== @@ -414,10 +449,13 @@ device_auth(Config, Scope, Opts) -> end, FlowOpts = [{open_browser, OpenBrowser}], case mix_hex_api_oauth:device_auth_flow(Config, ClientId, Scope, PromptUser, FlowOpts) of - {ok, #{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt}} -> + {ok, + #{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt} = + Tokens} -> ok = call_callback(Config, persist_oauth_tokens, [ global, AccessToken, RefreshToken, ExpiresAt ]), + report_sso_reauth(Config, Tokens), {ok, #{ access_token => AccessToken, refresh_token => RefreshToken, @@ -650,6 +688,7 @@ maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when ok = call_callback(Config, persist_oauth_tokens, [ global, NewAccessToken, NewRefreshToken, ExpiresAt ]), + report_sso_reauth(Config, TokenResponse), BearerToken = <<"Bearer ", NewAccessToken/binary>>, HasRefreshToken = is_binary(NewRefreshToken), {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}}; @@ -782,6 +821,19 @@ 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. +%% Always called after a grant, including with the empty list, so a set that +%% has been resolved does not linger. +report_sso_reauth(Config, #{sso_reauth_required := Organizations}) when is_list(Organizations) -> + maybe_call_callback(Config, sso_reauth, [Organizations]); +report_sso_reauth(Config, #{<<"sso_reauth_required">> := Organizations}) when + is_list(Organizations) +-> + maybe_call_callback(Config, sso_reauth, [Organizations]); +report_sso_reauth(Config, _Tokens) -> + maybe_call_callback(Config, sso_reauth, [[]]). + %% @private %% Like call_callback/3 but for optional callbacks: returns ok without doing %% anything when the callback is not provided. diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index 859370a5..1ad4dd9e 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% `hex_core' entrypoint module. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index 32de2590..9e878b3f 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually --define(HEX_CORE_VERSION, "0.18.0"). +-define(HEX_CORE_VERSION, "0.19.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index 8c0251d0..cbfb6270 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% This file is a copy of erl_tar.erl from OTP with the following modifications: %% 1. Module renamed from erl_tar to mix_hex_erl_tar diff --git a/src/mix_hex_erl_tar.hrl b/src/mix_hex_erl_tar.hrl index 809726c5..c37392df 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% This file is a copy of erl_tar.hrl from OTP with the following modifications: %% 1. Added chunk_size field to #read_opts{} for streaming extraction to disk diff --git a/src/mix_hex_http.erl b/src/mix_hex_http.erl index fdd845b9..d8467e70 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index ac9608eb..5abbb778 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% httpc-based implementation of {@link mix_hex_http} contract. diff --git a/src/mix_hex_licenses.erl b/src/mix_hex_licenses.erl index a75ef4c3..35c0185b 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index 9c37781c..724339a3 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_package.erl b/src/mix_hex_pb_package.erl index 2981bee5..4cf33a9d 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_policy.erl b/src/mix_hex_pb_policy.erl index 103dc43a..f3598b67 100644 --- a/src/mix_hex_pb_policy.erl +++ b/src/mix_hex_pb_policy.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_signed.erl b/src/mix_hex_pb_signed.erl index 7627affa..b4deb508 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_pb_versions.erl b/src/mix_hex_pb_versions.erl index b4a005d4..2a1294ec 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% -*- coding: utf-8 -*- %% % this file is @generated diff --git a/src/mix_hex_registry.erl b/src/mix_hex_registry.erl index 90043655..6e281ad3 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index ea13b3ea..67dec3da 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Repo API. diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index 21706fdb..d482b450 100644 --- a/src/mix_hex_safe_binary_to_term.erl +++ b/src/mix_hex_safe_binary_to_term.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @hidden %% Safe deserialization of Erlang terms from binary. diff --git a/src/mix_hex_tarball.erl b/src/mix_hex_tarball.erl index d3d3bbef..1ff57a9b 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %% @doc %% Functions for creating and unpacking Hex tarballs. diff --git a/src/mix_safe_erl_term.xrl b/src/mix_safe_erl_term.xrl index 302463b4..7b6c6811 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually +%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. diff --git a/test/hex/auth_test.exs b/test/hex/auth_test.exs index 7cca2c15..a9777de3 100644 --- a/test/hex/auth_test.exs +++ b/test/hex/auth_test.exs @@ -86,4 +86,125 @@ defmodule Hex.AuthTest do end) end end + + describe "SSO re-authentication" do + test "stores the organizations the server flagged with the token" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + + assert Hex.Auth.callbacks().sso_reauth.(["acme"]) == :ok + + assert Hex.OAuth.sso_reauth_required() == ["acme"] + assert Hex.Config.read()[:"$oauth_token"][:sso_reauth_required] == ["acme"] + end) + end + + test "drops them again once nothing is flagged" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + + assert Hex.Auth.callbacks().sso_reauth.(["acme"]) == :ok + assert Hex.Auth.callbacks().sso_reauth.([]) == :ok + + assert Hex.OAuth.sso_reauth_required() == [] + refute Map.has_key?(Hex.State.get(:oauth_token), :sso_reauth_required) + end) + end + + test "asks only about the organizations this resolution needs" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme", "widgets"]) + + send(self(), {:mix_shell_input, :yes?, false}) + Hex.RemoteConverger.check_sso_reauth([{"hexpm:acme", "foo"}, {"hexpm", "ecto"}]) + + assert_received {:mix_shell, :yes?, [question]} + assert question =~ "acme requires SSO authentication" + refute question =~ "widgets" + end) + end + + test "says what declining costs" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme"]) + + send(self(), {:mix_shell_input, :yes?, false}) + Hex.RemoteConverger.check_sso_reauth([{"hexpm:acme", "foo"}]) + + assert_received {:mix_shell, :yes?, _question} + assert Case.shell_output() =~ "Packages from acme will not be available" + end) + end + + test "asks nothing about an organization authenticated with its own key" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme"]) + repos = Hex.State.fetch!(:repos) + hexpm = repos["hexpm"] + + Hex.State.put( + :repos, + Map.put(repos, "hexpm:acme", %{hexpm | auth_key: "org-key"}) + ) + + assert Hex.RemoteConverger.check_sso_reauth([{"hexpm:acme", "foo"}]) == :ok + assert Case.shell_output() == "" + end) + end + + test "says so rather than asking when Hex is offline" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.State.put(:offline, true) + + Hex.RemoteConverger.check_sso_reauth([{"hexpm:acme", "foo"}]) + + refute_received {:mix_shell, :yes?, _question} + assert Case.shell_output() =~ "Hex is offline" + end) + end + + test "says so rather than asking when HEX_API_KEY is what authenticates" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.State.put(:api_key, "key") + + Hex.RemoteConverger.check_sso_reauth([{"hexpm:acme", "foo"}]) + + refute_received {:mix_shell, :yes?, _question} + assert Case.shell_output() =~ "HEX_API_KEY" + end) + end + + test "asks nothing when the project needs none of the flagged organizations" do + in_tmp("sso_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().sso_reauth.(["acme"]) + + assert Hex.RemoteConverger.check_sso_reauth([{"hexpm", "ecto"}]) == :ok + assert Case.shell_output() == "" + end) + end + end + + defp store_token do + Hex.OAuth.store_token(%{ + access_token: "token", + refresh_token: "refresh", + expires_at: System.system_time(:second) + 3600 + }) + end end