diff --git a/lib/hex/api/oauth.ex b/lib/hex/api/oauth.ex index 02c5fa10..fe4a1bfd 100644 --- a/lib/hex/api/oauth.ex +++ b/lib/hex/api/oauth.ex @@ -26,36 +26,36 @@ defmodule Hex.API.OAuth do config = Client.config() case :mix_hex_api_oauth.device_auth_flow(config, @client_id, scopes, prompt_user, opts) do - {:ok, tokens} -> {:ok, drop_empty_sso_reauth_required(tokens)} + {:ok, tokens} -> {:ok, drop_empty_organization_reauth_required(tokens)} other -> other end end # :mix_hex_api_oauth reports "nothing is flagged" as an empty list. A stored # token map carries the key only when there is something in it. - defp drop_empty_sso_reauth_required(%{sso_reauth_required: []} = tokens) do - Map.delete(tokens, :sso_reauth_required) + defp drop_empty_organization_reauth_required(%{organization_reauth_required: []} = tokens) do + Map.delete(tokens, :organization_reauth_required) end - defp drop_empty_sso_reauth_required(tokens), do: tokens + defp drop_empty_organization_reauth_required(tokens), do: tokens @doc """ - Requests a URL for authenticating this session against organizations that - require single sign-on. + Requests a browser URL to complete the organization's SSO and 2FA requirements + for this OAuth session. ## Examples - iex> Hex.API.OAuth.sso_authorization(["acme"]) - {:ok, {201, _headers, %{"verification_uri" => "https://hex.pm/sso/authorize/...", + iex> Hex.API.OAuth.organization_authorization(["acme"]) + {:ok, {201, _headers, %{"verification_uri" => "https://hex.pm/organizations/authorize?code=...", "expires_in" => 600}}} """ - def sso_authorization(organizations) do + def organization_authorization(organizations) do config = Client.config() Hex.Auth.with_session_api( :read, config, - fn config -> :mix_hex_api_oauth.sso_authorization(config, organizations) end, + fn config -> :mix_hex_api_oauth.organization_authorization(config, organizations) end, auth_inline: false ) end diff --git a/lib/hex/auth.ex b/lib/hex/auth.ex index 3b028f3e..cc3451fd 100644 --- a/lib/hex/auth.ex +++ b/lib/hex/auth.ex @@ -41,9 +41,9 @@ defmodule Hex.Auth do @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. + Satisfying an organization's SSO or 2FA requirements grants scopes absent + from the current access token. Refreshing retrieves those scopes without + waiting for the token to expire. """ def refresh_tokens(config) do :mix_hex_cli_auth.refresh_tokens(config) @@ -65,7 +65,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, + organization_reauth: &organization_reauth/1, prompt_otp: &prompt_otp/1, get_client_id: &Hex.API.OAuth.client_id/0, should_authenticate: &should_authenticate/1 @@ -94,10 +94,10 @@ defmodule Hex.Auth do defp persist_oauth_tokens(repo, access_token, refresh_token, expires_at) defp persist_oauth_tokens(:global, access_token, refresh_token, expires_at) do - # The flagged organizations arrive through the sso_reauth callback, not with + # The flagged organizations arrive through the organization_reauth callback, not with # the token, so carry them over instead of dropping them on every refresh. token_data = - token_map(access_token, expires_at, refresh_token, Hex.OAuth.sso_reauth_required()) + token_map(access_token, expires_at, refresh_token, Hex.OAuth.organization_reauth_required()) Hex.OAuth.store_token(token_data) :ok @@ -111,7 +111,7 @@ defmodule Hex.Auth do :ok end - defp token_map(access_token, expires_at, refresh_token, sso_reauth_required \\ []) do + defp token_map(access_token, expires_at, refresh_token, organization_reauth_required \\ []) do token_data = %{access_token: access_token, expires_at: expires_at} token_data = @@ -119,7 +119,7 @@ defmodule Hex.Auth do do: Map.put(token_data, :refresh_token, refresh_token), else: token_data - put_sso_reauth(token_data, sso_reauth_required) + put_organization_reauth(token_data, organization_reauth_required) end # Invoked by hex_cli_auth when the stored global OAuth token is expired and @@ -141,25 +141,25 @@ 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 + # Invoked by hex_cli_auth after every token grant with outstanding organization + # authentication requirements. Store them with the token so commands can select + # the organizations they need, including when reusing a token without refreshing. + defp organization_reauth(organizations) do token_data = Hex.State.get(:oauth_token) - if is_map(token_data) and Map.get(token_data, :sso_reauth_required, []) != organizations do - Hex.OAuth.store_token(put_sso_reauth(token_data, organizations)) + if is_map(token_data) and + Map.get(token_data, :organization_reauth_required, []) != organizations do + Hex.OAuth.store_token(put_organization_reauth(token_data, organizations)) end :ok end - defp put_sso_reauth(token_data, []), do: Map.delete(token_data, :sso_reauth_required) + defp put_organization_reauth(token_data, []), + do: Map.delete(token_data, :organization_reauth_required) - defp put_sso_reauth(token_data, organizations), - do: Map.put(token_data, :sso_reauth_required, organizations) + defp put_organization_reauth(token_data, organizations), + do: Map.put(token_data, :organization_reauth_required, organizations) # A prompt answers :eof when there is nothing on stdin to read, which is what # an OTP challenge in CI gets. diff --git a/lib/hex/oauth.ex b/lib/hex/oauth.ex index 65101929..1303a3f3 100644 --- a/lib/hex/oauth.ex +++ b/lib/hex/oauth.ex @@ -19,12 +19,12 @@ defmodule Hex.OAuth do end @doc """ - The organizations the stored session has to authenticate through their - identity provider for before it can reach them again. + The organizations and authentication requirements the stored session must + satisfy before it can reach them again. """ - def sso_reauth_required do + def organization_reauth_required do case Hex.State.get(:oauth_token) do - %{sso_reauth_required: organizations} when is_list(organizations) -> organizations + %{organization_reauth_required: organizations} when is_list(organizations) -> organizations _token_data -> [] end end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index b5f2a705..8aa49f05 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -63,7 +63,7 @@ defmodule Hex.RemoteConverger do organizations = user_oauth_organizations(prefetches) check_and_refresh_auth(organizations) - check_sso_reauth(organizations) + check_organization_reauth(organizations) Registry.prefetch(prefetches) locked = prepare_locked(lock, old_lock, deps) @@ -968,70 +968,62 @@ defmodule Hex.RemoteConverger do # 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 + # than a 403 at a time, and it is why a member of ten organizations who # depends on two is asked about two. @doc false - def check_sso_reauth(organizations) do - Hex.OAuth.sso_reauth_required() - |> Enum.filter(&(&1 in organizations)) - |> prompt_sso_reauth() + def check_organization_reauth(organizations) do + Hex.OAuth.organization_reauth_required() + |> Enum.filter(&(&1.organization in organizations)) + |> prompt_organization_reauth() end - defp prompt_sso_reauth([]), do: :ok + defp prompt_organization_reauth([]), do: :ok - defp prompt_sso_reauth(organizations) do + defp prompt_organization_reauth(entries) do cond do Hex.State.fetch!(:offline) -> - unavailable(organizations, "Hex is offline") + unavailable(entries, "Hex is offline") - Hex.Shell.yes?("#{sso_subject(organizations)} SSO authentication. Authenticate now?") -> - start_sso_reauth(organizations) + Hex.Shell.yes?("#{requirements(entries)}. Authenticate now?") -> + start_organization_reauth(entries) true -> - Hex.Shell.warn("Packages from #{names(organizations)} will not be available.") + Hex.Shell.warn("Packages from #{names(entries)} will not be available.") end end - defp unavailable(organizations, reason) do + defp unavailable(entries, reason) do Hex.Shell.warn( - "#{sso_subject(organizations)} SSO authentication, but #{reason}. " <> - "Packages from #{names(organizations)} will not be available." + "#{requirements(entries)}, but #{reason}. Packages from #{names(entries)} 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}}} + defp start_organization_reauth(entries) do + case Hex.API.OAuth.organization_authorization(Enum.map(entries, & &1.organization)) do + {:ok, {status, _, %{"verification_uri" => uri}}} when status in 200..299 and is_binary(uri) -> uri = Hex.Utils.printable_ascii(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) - - # The server's message never names a client command, since it cannot know - # which client asked, so the mix task goes in here. A full `mix hex.user - # auth` re-establishes organization access at approval, which makes it - # the fallback whatever kept the in-place flow from starting. - {:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) -> + + # The server decides whether the request was completed; the refresh + # reads its answer, however long the prompt sat open. + case Hex.Shell.prompt("Open #{uri} to authenticate, then press enter") do + answer when is_binary(answer) -> finish_organization_reauth(entries) + _ -> unavailable(entries, "authentication was cancelled") + end + + {:ok, {_status, _, %{"message" => message}}} when is_binary(message) -> Hex.Shell.warn( - "Could not start SSO authentication: #{Hex.Utils.escape_terminal(message)}. " <> - "Run `mix hex.user auth` to authenticate again." + "Could not start organization authentication: #{Hex.Utils.escape_terminal(message)}. Run `mix hex.user auth` to authenticate again." ) - _other -> + _ -> Hex.Shell.warn( - "Could not start SSO authentication. Run `mix hex.user auth` to authenticate again." + "Could not start organization authentication. Run `mix hex.user auth` to authenticate again." ) end end - # Opening a browser is a convenience on top of the printed URL, so nothing it - # does is worth ending a resolution over: System.cmd/2 raises when the - # platform has no opener installed. defp open_browser(uri) do case URI.parse(uri) do %URI{scheme: scheme} when scheme in ["http", "https"] -> @@ -1041,32 +1033,36 @@ defmodule Hex.RemoteConverger do _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 + defp finish_organization_reauth(entries) do config = Hex.API.Client.config([]) + names = Enum.map(entries, & &1.organization) with :ok <- Hex.Auth.refresh_tokens(config), - [] <- Enum.filter(Hex.OAuth.sso_reauth_required(), &(&1 in organizations)) do + [] <- Enum.filter(Hex.OAuth.organization_reauth_required(), &(&1.organization in names)) do :ok else - _other -> - Hex.Shell.warn( - "#{sso_subject(organizations)} SSO authentication. " <> - "Packages from #{names(organizations)} will not be available." - ) + _ -> unavailable(entries, "authentication is incomplete") end end - defp sso_subject([organization]), do: "#{organization} requires" - defp sso_subject(organizations), do: "#{names(organizations)} require" + defp requirements(entries) do + Enum.map_join(entries, "; ", fn entry -> + reasons = + Enum.map_join(entry.requirements, ", ", fn + "tfa" -> "2FA enrollment required" + "sso" -> "SSO authentication required" + end) + + "#{entry.organization}: #{reasons}" + end) + end - defp names(organizations), do: Enum.join(organizations, ", ") + defp names(entries), do: Enum.map_join(entries, ", ", & &1.organization) # The organizations among the prefetched repositories that the stored user # session authenticates for. An organization with its own key does not touch diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index e6db5898..4f997957 100644 --- a/src/mix_hex_advisory.erl +++ b/src/mix_hex_advisory.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 213fec97..2b4c6da7 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 fecf88d1..96a8f09f 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 7375c0d9..64b3f3f5 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 354a9956..fcfc628a 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% Hex HTTP API - OAuth. @@ -10,8 +10,8 @@ device_auth_flow/5, poll_device_token/3, refresh_token/3, - sso_authorization/2, - sso_reauth_required/1, + organization_authorization/2, + organization_reauth_required/1, revoke_token/3, client_credentials_token/4, client_credentials_token/5, @@ -24,10 +24,10 @@ access_token := binary(), 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()] + %% Organizations whose SSO or 2FA requirements the session must satisfy. + %% Their scopes are absent until verification is complete; see + %% organization_authorization/2. + organization_reauth_required => [map()] }. -type device_auth_error() :: @@ -207,7 +207,7 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) -> expires_at => erlang:system_time(second) + ExpiresIn }, {ok, - put_sso_reauth_required( + put_organization_reauth_required( put_refresh_token(Tokens, TokenResponse), TokenResponse )}; error -> @@ -295,27 +295,27 @@ 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. +%% Requests a browser URL to verify the organizations' SSO and 2FA requirements +%% for the current OAuth session. %% %% 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. +%% owner completes the required verification in a browser. 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">>]). +%% 2> mix_hex_api_oauth:organization_authorization(Config, [<<"acme">>]). %% {ok, {201, _, #{ -%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>, +%% <<"verification_uri">> => <<"https://hex.pm/organizations/authorize?code=...">>, %% <<"expires_in">> => 600 %% }}} %% ''' %% @end --spec sso_authorization(mix_hex_core:config(), [binary()]) -> mix_hex_api:response(). -sso_authorization(Config, Organizations) -> - Path = <<"oauth/sso_authorization">>, +-spec organization_authorization(mix_hex_core:config(), [binary()]) -> mix_hex_api:response(). +organization_authorization(Config, Organizations) -> + Path = <<"oauth/organization_authorization">>, mix_hex_api:post(Config, Path, #{<<"organizations">> => Organizations}). %% @doc @@ -400,25 +400,51 @@ revoke_token(Config, ClientId, Token) -> mix_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. +%% Missing organization authentication requirements in a token response. +%% An absent field means no outstanding requirements. Malformed fields are +%% rejected so callers cannot report successful authentication from them. %% @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}) -> +-spec organization_reauth_required(map()) -> {ok, [map()]} | error. +organization_reauth_required(#{<<"organization_reauth_required">> := Entries}) when + is_list(Entries) +-> + parse_organization_requirements(Entries, []); +organization_reauth_required(#{<<"organization_reauth_required">> := _}) -> error; -sso_reauth_required(_TokenResponse) -> +organization_reauth_required(_) -> {ok, []}. +parse_organization_requirements([], Acc) -> + {ok, lists:reverse(Acc)}; +parse_organization_requirements( + [ + #{<<"organization">> := Name, <<"requirements">> := Requirements} | Rest + ], + Acc +) when is_binary(Name), byte_size(Name) > 0, Requirements =/= [] -> + case valid_requirements(Requirements) of + true -> + parse_organization_requirements( + Rest, + [#{organization => Name, requirements => lists:usort(Requirements)} | Acc] + ); + false -> + error + end; +parse_organization_requirements(_, _) -> + error. + +%% A proper list of known requirements. Decoded terms can carry an improper +%% list, which is_list/1 accepts and lists:all/2 would crash on. +valid_requirements([]) -> + true; +valid_requirements([Requirement | Rest]) when + Requirement =:= <<"tfa">>; Requirement =:= <<"sso">> +-> + valid_requirements(Rest); +valid_requirements(_) -> + false. + %%==================================================================== %% Internal functions %%==================================================================== @@ -516,11 +542,11 @@ put_refresh_token(Tokens, _TokenResponse) -> Tokens. %% @private -%% A response whose sso_reauth_required cannot be read carries no key, so the +%% A response whose organization_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}; +put_organization_reauth_required(Tokens, TokenResponse) -> + case organization_reauth_required(TokenResponse) of + {ok, Organizations} -> Tokens#{organization_reauth_required => Organizations}; error -> Tokens end. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index f31a8b3d..043722fc 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 acb7c00d..33f2e00c 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.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 9264bd38..f6015e3c 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 c3c3452c..0baa0e99 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.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 c4409cef..e47ea3b6 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 7f18a870..fc2b8f01 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.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 ad226a50..e9b411a8 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 5f8cd3f3..8f6dd1d6 100644 --- a/src/mix_hex_cli_auth.erl +++ b/src/mix_hex_cli_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% Authentication handling with callback functions for build-tool-specific operations. @@ -38,12 +38,12 @@ %% clear_oauth_tokens => fun(() -> ok), %% %% %% Report the organizations the server says this session has to -%% %% authenticate against their identity provider for (optional). Called +%% %% complete SSO or 2FA verification 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), +%% organization_reauth => fun(([map()]) -> ok), %% %% %% User interaction %% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), @@ -141,7 +141,7 @@ ) -> ok ), clear_oauth_tokens => fun(() -> ok), - sso_reauth => fun((Organizations :: [binary()]) -> ok), + organization_reauth => fun((Organizations :: [map()]) -> ok), prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()), get_client_id := fun(() -> binary()) @@ -517,7 +517,7 @@ renew_repo_auth_and_retry(BaseConfig, Fun, RepoKey, Response) -> %% 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 +%% session for an organization's authentication requirements 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()}. @@ -561,12 +561,12 @@ device_auth(Config, Scope, Opts) -> FlowOpts = [{open_browser, OpenBrowser}], case mix_hex_api_oauth:device_auth_flow(Config, ClientId, Scope, PromptUser, FlowOpts) of {ok, Response} -> - %% sso_reauth_required reaches the build tool through the sso_reauth + %% organization_reauth_required reaches the build tool through the organization_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), + Tokens = maps:without([organization_reauth_required], Response), ok = persist_tokens(Config, global, Tokens), - report_sso_reauth(Config, maps:find(sso_reauth_required, Response)), + report_organization_reauth(Config, maps:find(organization_reauth_required, Response)), {ok, Tokens}; {error, timeout} -> {error, {auth_error, device_auth_timeout}}; @@ -822,7 +822,9 @@ maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when expires_at => erlang:system_time(second) + ExpiresIn }, ok = persist_tokens(Config, global, NewTokens), - report_sso_reauth(Config, mix_hex_api_oauth:sso_reauth_required(TokenResponse)), + report_organization_reauth( + Config, mix_hex_api_oauth:organization_reauth_required(TokenResponse) + ), BearerToken = <<"Bearer ", NewAccessToken/binary>>, {ok, BearerToken, #{has_refresh_token => has_refresh_token(NewTokens)}}; {ok, {Status, _, _Body}} when Status =:= 400; Status =:= 401 -> @@ -1003,9 +1005,9 @@ call_callback(Config, Name, Args) -> %% 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) -> +report_organization_reauth(Config, {ok, Organizations}) when is_list(Organizations) -> + maybe_call_callback(Config, organization_reauth, [Organizations]); +report_organization_reauth(_Config, error) -> ok. %% @private diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index 5d3a0847..a83d2a11 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% `hex_core' entrypoint module. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index c50ac4b5..54177791 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually -define(HEX_CORE_VERSION, "0.19.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index 70aa3028..94de7373 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 0ca12cd5..2a5726de 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 7715b576..ee184726 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index ad48235e..45b92843 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 0952e026..93b96847 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index b9380ba5..1463a194 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 f711f053..0479e47f 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 d6d25c49..7550815d 100644 --- a/src/mix_hex_pb_policy.erl +++ b/src/mix_hex_pb_policy.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 bac77399..e5c5c930 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 8017cdff..e6e0ee4f 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 a17fb023..c913f724 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 e275034f..e765365a 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 0ef9e846..c7af7cad 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.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 79da94c1..086753d9 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 11537877..68d9e2ad 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.19.0 (9ea52a0), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), 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 b7d93d3e..ddafa277 100644 --- a/test/hex/auth_test.exs +++ b/test/hex/auth_test.exs @@ -133,37 +133,39 @@ defmodule Hex.AuthTest do end end - describe "SSO re-authentication" do + describe "organization re-authentication" do test "stores the organizations the server flagged with the token" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - assert Hex.Auth.callbacks().sso_reauth.(["acme"]) == :ok + assert Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) == :ok - assert Hex.OAuth.sso_reauth_required() == ["acme"] - assert Hex.Config.read()[:"$oauth_token"][:sso_reauth_required] == ["acme"] + assert Hex.OAuth.organization_reauth_required() == requirements(["acme"]) + + assert Hex.Config.read()[:"$oauth_token"][:organization_reauth_required] == + requirements(["acme"]) end) end test "drops them again once nothing is flagged" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - assert Hex.Auth.callbacks().sso_reauth.(["acme"]) == :ok - assert Hex.Auth.callbacks().sso_reauth.([]) == :ok + assert Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) == :ok + assert Hex.Auth.callbacks().organization_reauth.([]) == :ok - assert Hex.OAuth.sso_reauth_required() == [] - refute Map.has_key?(Hex.State.get(:oauth_token), :sso_reauth_required) + assert Hex.OAuth.organization_reauth_required() == [] + refute Map.has_key?(Hex.State.get(:oauth_token), :organization_reauth_required) end) end test "keeps them when a refreshed token is persisted" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) expires_at = System.system_time(:second) + 3600 @@ -175,34 +177,36 @@ defmodule Hex.AuthTest do ) == :ok assert Hex.State.get(:oauth_token).access_token == "refreshed" - assert Hex.OAuth.sso_reauth_required() == ["acme"] - assert Hex.Config.read()[:"$oauth_token"][:sso_reauth_required] == ["acme"] + assert Hex.OAuth.organization_reauth_required() == requirements(["acme"]) + + assert Hex.Config.read()[:"$oauth_token"][:organization_reauth_required] == + requirements(["acme"]) end) end test "asks only about the organizations this resolution needs" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme", "widgets"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme", "widgets"])) send(self(), {:mix_shell_input, :yes?, false}) - check_sso_reauth([{"hexpm:acme", "foo"}, {"hexpm", "ecto"}]) + check_organization_reauth([{"hexpm:acme", "foo"}, {"hexpm", "ecto"}]) assert_received {:mix_shell, :yes?, [question]} - assert question =~ "acme requires SSO authentication" + assert question =~ "acme: SSO authentication required" refute question =~ "widgets" end) end test "says what declining costs" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) send(self(), {:mix_shell_input, :yes?, false}) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) assert_received {:mix_shell, :yes?, _question} assert Case.shell_output() =~ "Packages from acme will not be available" @@ -210,10 +214,10 @@ defmodule Hex.AuthTest do end test "asks nothing about an organization authenticated with its own key" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) repos = Hex.State.fetch!(:repos) hexpm = repos["hexpm"] @@ -222,19 +226,19 @@ defmodule Hex.AuthTest do Map.put(repos, "hexpm:acme", %{hexpm | auth_key: "org-key"}) ) - assert check_sso_reauth([{"hexpm:acme", "foo"}]) == :ok + assert check_organization_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 -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) Hex.State.put(:offline, true) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) refute_received {:mix_shell, :yes?, _question} assert Case.shell_output() =~ "Hex is offline" @@ -242,76 +246,97 @@ defmodule Hex.AuthTest do end test "asks nothing when the project needs none of the flagged organizations" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) - assert check_sso_reauth([{"hexpm", "ecto"}]) == :ok + assert check_organization_reauth([{"hexpm", "ecto"}]) == :ok assert Case.shell_output() == "" end) end test "authenticates and picks the scopes up on the forced refresh" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) - stub_sso_authorization(%{}) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) + stub_organization_authorization(%{}) send(self(), {:mix_shell_input, :yes?, true}) send(self(), {:mix_shell_input, :prompt, ""}) - assert check_sso_reauth([{"hexpm:acme", "foo"}]) == :ok + assert check_organization_reauth([{"hexpm:acme", "foo"}]) == :ok assert_received {:hex_system_cmd, _cmd, args} - assert "https://hex.pm/sso/authorize/acme" in args + assert "https://hex.pm/organizations/authorize/acme" in args - assert Hex.OAuth.sso_reauth_required() == [] + assert Hex.OAuth.organization_reauth_required() == [] assert Hex.State.get(:oauth_token).access_token == "renewed" assert Case.shell_output() == "" end) end + test "refreshes after the prompt even when the verification URL has expired" do + in_tmp("organization_reauth", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) + stub_organization_authorization(%{}, nil, 0) + + send(self(), {:mix_shell_input, :yes?, true}) + send(self(), {:mix_shell_input, :prompt, ""}) + + assert check_organization_reauth([{"hexpm:acme", "foo"}]) == :ok + assert Hex.OAuth.organization_reauth_required() == [] + assert Hex.State.get(:oauth_token).access_token == "renewed" + end) + end + test "asks for the URL as the stored session rather than as HEX_API_KEY" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) Hex.State.put(:api_key, "env_api_key") - stub_sso_authorization(%{}) + stub_organization_authorization(%{}) send(self(), {:mix_shell_input, :yes?, true}) send(self(), {:mix_shell_input, :prompt, ""}) - assert check_sso_reauth([{"hexpm:acme", "foo"}]) == :ok + assert check_organization_reauth([{"hexpm:acme", "foo"}]) == :ok - assert_received {:sso_authorization_header, "Bearer token"} + assert_received {:organization_authorization_header, "Bearer token"} end) end test "says what is unavailable when the session is still lapsed afterwards" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) - stub_sso_authorization(%{"sso_reauth_required" => ["acme"]}) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) + + stub_organization_authorization(%{ + "organization_reauth_required" => [ + %{"organization" => "acme", "requirements" => ["sso"]} + ] + }) send(self(), {:mix_shell_input, :yes?, true}) send(self(), {:mix_shell_input, :prompt, ""}) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) - assert Hex.OAuth.sso_reauth_required() == ["acme"] + assert Hex.OAuth.organization_reauth_required() == requirements(["acme"]) assert Case.shell_output() =~ "Packages from acme will not be available" end) end test "adds the mix task when the server refuses to start" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) # The server's refusal never names a client command, since it cannot # know which client asked, so the task comes from here. @@ -319,7 +344,7 @@ defmodule Hex.AuthTest do Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") Bypass.expect(bypass, fn conn -> - assert conn.request_path == "/api/oauth/sso_authorization" + assert conn.request_path == "/api/oauth/organization_authorization" erlang_resp(conn, 422, %{ "message" => "SSO re-authorization is for an OAuth session" @@ -328,7 +353,7 @@ defmodule Hex.AuthTest do send(self(), {:mix_shell_input, :yes?, true}) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) output = Case.shell_output() assert output =~ "SSO re-authorization is for an OAuth session" @@ -337,16 +362,16 @@ defmodule Hex.AuthTest do end test "prints the authorization URL without the characters a terminal acts on" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) - stub_sso_authorization(%{}, "https://hex.pm/sso/\e]0;pwned\a\nauthorize") + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) + stub_organization_authorization(%{}, "https://hex.pm/sso/\e]0;pwned\a\nauthorize") send(self(), {:mix_shell_input, :yes?, true}) send(self(), {:mix_shell_input, :prompt, ""}) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) assert_received {:mix_shell, :prompt, [prompt]} @@ -359,10 +384,10 @@ defmodule Hex.AuthTest do end test "says so when the authorization URL cannot be requested" do - in_tmp("sso_reauth", fn -> + in_tmp("organization_reauth", fn -> set_home_cwd() store_token() - Hex.Auth.callbacks().sso_reauth.(["acme"]) + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) bypass = Bypass.open() Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") @@ -373,7 +398,7 @@ defmodule Hex.AuthTest do send(self(), {:mix_shell_input, :yes?, true}) - check_sso_reauth([{"hexpm:acme", "foo"}]) + check_organization_reauth([{"hexpm:acme", "foo"}]) refute_received {:hex_system_cmd, _cmd, _args} assert Case.shell_output() =~ "acme does not use SSO" @@ -381,6 +406,69 @@ defmodule Hex.AuthTest do end end + test "shows both requirements and retains them after a cancelled prompt" do + in_tmp("organization_requirements", fn -> + set_home_cwd() + store_token() + entries = [%{organization: "acme", requirements: ["tfa", "sso"]}] + Hex.Auth.callbacks().organization_reauth.(entries) + send(self(), {:mix_shell_input, :yes?, false}) + check_organization_reauth([{"hexpm:acme", "foo"}]) + assert_received {:mix_shell, :yes?, [question]} + assert question =~ "acme: 2FA enrollment required, SSO authentication required" + assert Hex.OAuth.organization_reauth_required() == entries + end) + end + + test "EOF after opening the browser doesn't refresh or report authentication success" do + in_tmp("organization_requirements_eof", fn -> + set_home_cwd() + store_token() + Hex.Auth.callbacks().organization_reauth.(requirements(["acme"])) + bypass = Bypass.open() + Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") + + Bypass.expect_once(bypass, "POST", "/api/oauth/organization_authorization", fn conn -> + erlang_resp(conn, 201, %{ + "verification_uri" => "https://hex.pm/organizations/authorize?code=example", + "expires_in" => 600 + }) + end) + + send(self(), {:mix_shell_input, :yes?, true}) + send(self(), {:mix_shell_input, :prompt, :eof}) + check_organization_reauth([{"hexpm:acme", "foo"}]) + assert Hex.State.get(:oauth_token).access_token == "token" + assert Hex.OAuth.organization_reauth_required() == requirements(["acme"]) + assert Case.shell_output() =~ "authentication was cancelled" + end) + end + + test "a noninteractive shell with EOF retains missing verification without starting a browser" do + in_tmp("organization_requirements_noninteractive", fn -> + set_home_cwd() + store_token() + entries = [%{organization: "acme", requirements: ["tfa", "sso"]}] + Hex.Auth.callbacks().organization_reauth.(entries) + previous = Mix.shell() + Mix.shell(Mix.Shell.IO) + + try do + ExUnit.CaptureIO.capture_io(:stderr, fn -> + ExUnit.CaptureIO.capture_io("", fn -> + check_organization_reauth([{"hexpm:acme", "foo"}]) + end) + end) + after + Mix.shell(previous) + end + + refute_received {:hex_system_cmd, _, _} + assert Hex.OAuth.organization_reauth_required() == entries + assert Hex.State.get(:oauth_token).access_token == "token" + end) + end + describe "authentication preflight" do test "asks to authenticate when a private organization is needed and nothing is stored" do in_tmp("preflight", fn -> @@ -468,12 +556,17 @@ defmodule Hex.AuthTest do }) Hex.State.put(:api_key, "env_api_key") - stub_token_refresh(%{"sso_reauth_required" => ["acme"]}) + + stub_token_refresh(%{ + "organization_reauth_required" => [ + %{"organization" => "acme", "requirements" => ["sso"]} + ] + }) assert Hex.RemoteConverger.check_and_refresh_auth(["acme"]) == :ok assert Hex.State.get(:oauth_token).access_token == "renewed" - assert Hex.OAuth.sso_reauth_required() == ["acme"] + assert Hex.OAuth.organization_reauth_required() == requirements(["acme"]) end) end @@ -498,36 +591,40 @@ defmodule Hex.AuthTest do end end - defp check_sso_reauth(prefetches) do + defp check_organization_reauth(prefetches) do prefetches |> Hex.RemoteConverger.user_oauth_organizations() - |> Hex.RemoteConverger.check_sso_reauth() + |> Hex.RemoteConverger.check_organization_reauth() end # Answers the two requests re-authorization makes: the URL the user opens, and # the refresh that picks up what completing it granted. Overrides go into the # refresh response, which is what says whether anything is still lapsed. - defp stub_sso_authorization(refresh_overrides, verification_uri \\ nil) do + defp stub_organization_authorization( + refresh_overrides, + verification_uri \\ nil, + expires_in \\ 600 + ) do bypass = Bypass.open() Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") test_pid = self() Bypass.expect(bypass, fn conn -> case conn.request_path do - "/api/oauth/sso_authorization" -> + "/api/oauth/organization_authorization" -> [header] = Plug.Conn.get_req_header(conn, "authorization") - send(test_pid, {:sso_authorization_header, header}) + send(test_pid, {:organization_authorization_header, header}) {:ok, body, conn} = Plug.Conn.read_body(conn) %{"organizations" => organizations} = :erlang.binary_to_term(body) uri = verification_uri || - "https://hex.pm/sso/authorize/#{Enum.join(organizations, "-")}" + "https://hex.pm/organizations/authorize/#{Enum.join(organizations, "-")}" erlang_resp(conn, 201, %{ "verification_uri" => uri, - "expires_in" => 600 + "expires_in" => expires_in }) "/api/oauth/token" -> @@ -574,4 +671,6 @@ defmodule Hex.AuthTest do expires_at: System.system_time(:second) + 3600 }) end + + defp requirements(names), do: Enum.map(names, &%{organization: &1, requirements: ["sso"]}) end diff --git a/test/hex/organization_auth_integration_test.exs b/test/hex/organization_auth_integration_test.exs new file mode 100644 index 00000000..da83d894 --- /dev/null +++ b/test/hex/organization_auth_integration_test.exs @@ -0,0 +1,97 @@ +defmodule Hex.OrganizationAuthIntegrationTest do + use HexTest.IntegrationCase, async: false + + test "the changed server filters enforcing organizations for unenrolled members and Hex reports enrollment" do + auth = Hexpm.new_oauth_user("tfa_client", "tfa_client@example.com", "hunter42") + auth = Keyword.put(auth, :oauth, true) + suffix = System.unique_integer([:positive]) + enforcing = "enforced_#{suffix}" + unaffected = "unaffected_#{suffix}" + assert {:ok, {204, _, _}} = Hexpm.new_repo(enforcing, auth) + assert {:ok, {204, _, _}} = Hexpm.new_repo(unaffected, auth) + config = Hex.API.Client.config(auth) + + assert {:ok, {204, _, _}} = + :mix_hex_api.post(config, ["organization_tfa"], %{"organization" => enforcing}) + + assert :ok = Hex.Auth.refresh_tokens(Hex.API.Client.config([])) + + assert Hex.OAuth.organization_reauth_required() == [ + %{organization: enforcing, requirements: ["tfa"]} + ] + + assert {:ok, {201, _, response}} = Hex.API.OAuth.organization_authorization([enforcing]) + assert response["verification_uri"] =~ "/organizations/authorize?code=" + assert response["expires_in"] > 0 + token = Hex.State.get(:oauth_token).access_token + [_header, payload, _signature] = String.split(token, ".") + assert {:ok, decoded} = Base.url_decode64(payload, padding: false) + assert is_binary(decoded) + assert decoded =~ "repository:#{unaffected}" + refute decoded =~ "repository:#{enforcing}" + send(self(), {:mix_shell_input, :yes?, false}) + Hex.RemoteConverger.check_organization_reauth([enforcing, unaffected]) + assert_received {:mix_shell, :yes?, [question]} + assert question =~ "#{enforcing}: 2FA enrollment required" + refute question =~ "SSO authentication required" + end + + test "an enrolled member keeps organization access when the policy starts" do + in_tmp("organization_tfa_enrolled_client", fn -> + set_home_cwd() + suffix = System.unique_integer([:positive]) + username = "enrolled_tfa_#{suffix}" + organization = "enrolled_tfa_org_#{suffix}" + secret = "JBSWY3DPEHPK3PXP" + config = Hex.API.Client.config() + + assert {:ok, {201, _, _}} = + :mix_hex_api.post(config, ["user"], %{ + "username" => username, + "email" => "#{username}@example.com", + "password" => "hunter42", + "tfa" => %{"secret" => secret} + }) + + assert {:ok, {200, _, response}} = + :mix_hex_api.post(config, ["oauth_token"], %{ + "username" => username, + "scope" => "api repositories" + }) + + auth = [key: response["access_token"], oauth: true, otp: totp(secret)] + assert {:ok, {204, _, _}} = Hexpm.new_repo(organization, auth) + + assert {:ok, {204, _, _}} = + :mix_hex_api.post(Hex.API.Client.config(auth), ["organization_tfa"], %{ + "organization" => organization + }) + + Hex.OAuth.store_token(%{ + access_token: response["access_token"], + refresh_token: response["refresh_token"], + expires_at: System.system_time(:second) + response["expires_in"] + }) + + assert :ok = Hex.Auth.refresh_tokens(Hex.API.Client.config()) + assert Hex.OAuth.organization_reauth_required() == [] + token = Hex.State.get(:oauth_token).access_token + [_header, payload, _signature] = String.split(token, ".") + assert {:ok, decoded} = Base.url_decode64(payload, padding: false) + assert decoded =~ "repository:#{organization}" + end) + end + + defp totp(secret) do + counter = div(System.system_time(:second), 30) + 1 + digest = :crypto.mac(:hmac, :sha, Base.decode32!(secret), <>) + offset = Bitwise.band(:binary.last(digest), 15) + <> = :binary.part(digest, offset, 4) + + code + |> Bitwise.band(0x7FFFFFFF) + |> rem(1_000_000) + |> Integer.to_string() + |> String.pad_leading(6, "0") + end +end diff --git a/test/hex/utils_test.exs b/test/hex/utils_test.exs index 93b66ab0..bef01849 100644 --- a/test/hex/utils_test.exs +++ b/test/hex/utils_test.exs @@ -104,8 +104,8 @@ defmodule Hex.UtilsTest do describe "win_cmd_args/1" do test "passes the empty title argument start expects" do - assert Hex.Utils.win_cmd_args("https://hex.pm/sso/authorize/abc") == - ["/c", "start", "", "https://hex.pm/sso/authorize/abc"] + assert Hex.Utils.win_cmd_args("https://hex.pm/organizations/authorize/abc") == + ["/c", "start", "", "https://hex.pm/organizations/authorize/abc"] end test "escapes the characters cmd.exe acts on" do diff --git a/test/support/hexpm.ex b/test/support/hexpm.ex index fa1dddcd..aa486b5d 100644 --- a/test/support/hexpm.ex +++ b/test/support/hexpm.ex @@ -72,7 +72,8 @@ defmodule HexTest.Hexpm do {~c"MIX_ARCHIVES", hexpm_mix_archives}, {~c"PATH", path}, {~c"HEX_SIGNING_KEY", key}, - {~c"HEXPM_SETUP", ~c"1"} + {~c"HEXPM_SETUP", ~c"1"}, + {~c"HEXPM_ORGANIZATION_TFA_MODE", ~c"enabled"} ] spawn(fn ->