From e408f8275b1d0bacda7507b1eac648a965453cd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 9 Sep 2026 15:07:49 -0500 Subject: [PATCH 1/6] Verify organization 2FA and SSO requirements before fetching --- lib/hex/api/oauth.ex | 20 +- lib/hex/auth.ex | 38 ++-- lib/hex/oauth.ex | 8 +- lib/hex/remote_converger.ex | 102 ++++----- src/mix_hex_advisory.erl | 2 +- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 87 +++++--- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_cli_auth.erl | 26 ++- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_policy.erl | 2 +- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 2 +- src/mix_hex_repo.erl | 2 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- test/hex/auth_test.exs | 209 ++++++++++++------ .../organization_auth_integration_test.exs | 120 ++++++++++ test/hex/utils_test.exs | 4 +- 37 files changed, 443 insertions(+), 227 deletions(-) create mode 100644 test/hex/organization_auth_integration_test.exs 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..bc7ceb2f 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,66 @@ 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}}} - when status in 200..299 and is_binary(uri) -> + defp start_organization_reauth(entries) do + case Hex.API.OAuth.organization_authorization(Enum.map(entries, & &1.organization)) do + {:ok, {status, _, %{"verification_uri" => uri, "expires_in" => expires_in}}} + when status in 200..299 and is_binary(uri) and is_integer(expires_in) and expires_in > 0 -> 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. + expires_at = System.monotonic_time(:second) + expires_in 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) -> + + case Hex.Shell.prompt("Open #{uri} to authenticate, then press enter") do + answer when is_binary(answer) -> + if System.monotonic_time(:second) < expires_at, + do: finish_organization_reauth(entries), + else: unavailable(entries, "the request expired") + + _ -> + 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 +1037,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 verification 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..3d8cb997 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 (b3757c5), 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..94494ec9 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 (b3757c5), 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..d03a43e4 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 (b3757c5), 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..4ab37357 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 (b3757c5), 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..cc557021 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 (b3757c5), 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,40 @@ 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, is_list(Requirements), Requirements =/= [] -> + case lists:all(fun(R) -> R =:= <<"tfa">> orelse R =:= <<"sso">> end, Requirements) of + true -> + parse_organization_requirements( + Rest, + [#{organization => Name, requirements => lists:usort(Requirements)} | Acc] + ); + false -> + error + end; +parse_organization_requirements(_, _) -> + error. + %%==================================================================== %% Internal functions %%==================================================================== @@ -516,11 +531,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..fcc6bb15 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 (b3757c5), 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..ae09bd07 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 (b3757c5), 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..8c2913cb 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 (b3757c5), 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..4349108b 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 (b3757c5), 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..4a0bb627 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 (b3757c5), 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..5cace74e 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 (b3757c5), 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..6eef2b8d 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 (b3757c5), 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..d2348c48 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 (b3757c5), 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..729c317f 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 (b3757c5), 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..64086544 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 (b3757c5), 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..002fa2b3 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 (b3757c5), 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..393a4afd 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 (b3757c5), 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..ffc0b479 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 (b3757c5), 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..e6a58528 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 (b3757c5), 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..2f285424 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 (b3757c5), 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..31517a10 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 (b3757c5), 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..c86078ec 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 (b3757c5), 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..a087c0fd 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 (b3757c5), 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..5eec697c 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 (b3757c5), 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..f82bedde 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 (b3757c5), 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..3461d01d 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 (b3757c5), 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..9c5e22bb 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 (b3757c5), 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..3a930d82 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 (b3757c5), 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..65690d1f 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 (b3757c5), 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..eaaeac1e 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 (b3757c5), 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..c301ed53 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,81 @@ 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 "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 +328,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 +337,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 +346,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 +368,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 +382,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 +390,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 verification 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 +540,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,32 +575,32 @@ 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) 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, @@ -574,4 +651,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..a24aa140 --- /dev/null +++ b/test/hex/organization_auth_integration_test.exs @@ -0,0 +1,120 @@ +defmodule Hex.OrganizationAuthIntegrationTest do + use HexTest.IntegrationCase, async: false + + test "the changed server filters enforcing organizations on refresh and Hex retains 2FA reasons" 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 verification required" + refute question =~ "SSO authentication required" + end + + test "an accepted API OTP restores organization access without another authentication prompt" do + in_tmp("organization_tfa_write_client_review", fn -> + set_home_cwd() + suffix = System.unique_integer([:positive]) + username = "review_tfa_#{suffix}" + organization = "review_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" + }) + + Hex.OAuth.store_token(%{ + access_token: response["access_token"], + refresh_token: response["refresh_token"], + expires_at: System.system_time(:second) + response["expires_in"] + }) + + auth = [key: response["access_token"], oauth: true, otp: totp(secret)] + assert {:ok, {204, _, _}} = Hexpm.new_repo(organization, auth) + Hexpm.new_package(organization, "review_tfa_pkg", "1.0.0", [], %{}, auth) + + assert {:ok, {204, _, _}} = + :mix_hex_api.post(Hex.API.Client.config(auth), ["organization_tfa"], %{ + "organization" => organization + }) + + assert {:ok, {200, _, fresh_session}} = + :mix_hex_api.post(config, ["oauth_token"], %{ + "username" => username, + "scope" => "api repositories" + }) + + Hex.OAuth.store_token(%{ + access_token: fresh_session["access_token"], + refresh_token: fresh_session["refresh_token"], + expires_at: System.system_time(:second) + fresh_session["expires_in"] + }) + + assert :ok = Hex.Auth.refresh_tokens(Hex.API.Client.config()) + + assert [%{organization: ^organization, requirements: ["tfa"]}] = + Hex.OAuth.organization_reauth_required() + + Hex.State.put(:api_otp, nil) + send(self(), {:mix_shell_input, :prompt, totp(secret)}) + Mix.Tasks.Hex.Owner.run(["add", "review_tfa_pkg", username, "--organization", organization]) + output = flush() + assert Enum.any?(output, &match?({:mix_shell, :prompt, ["Enter OTP code:"]}, &1)) + refute Enum.any?(output, &match?({:mix_shell, :yes?, _}, &1)) + refute Enum.any?(output, &match?({:mix_shell, :error, ["Adding owner failed"]}, &1)) + assert :ok = Hex.Auth.refresh_tokens(Hex.API.Client.config()) + assert Hex.OAuth.organization_reauth_required() == [] + assert {:ok, {200, _, owners}} = Hex.API.Package.Owner.get(organization, "review_tfa_pkg") + assert Enum.any?(owners, &(&1["username"] == username)) + 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-size(offset), code::32, _::binary>> = digest + + 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 From 56b42f7bc0a2f68d793dd6cd8b1cfe2e24d9b378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 9 Sep 2026 15:10:48 -0500 Subject: [PATCH 2/6] Test organization authentication against the server branch --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 24f84308..9e5384a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,7 +68,7 @@ jobs: env: HEXPM_OTP: OTP-28.5 HEXPM_ELIXIR: v1.19.5 - HEXPM_BRANCH: main + HEXPM_BRANCH: organization-tfa-enforcement HEXPM_PATH: hexpm HEXPM_ELIXIR_PATH: hexpm_elixir HEXPM_OTP_PATH: hexpm_otp From 2c6dbf98e882d31bfe09c774b779f3ebf155852e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 13:49:01 +0200 Subject: [PATCH 3/6] Report organization 2FA as an enrollment requirement The server no longer tracks per-session 2FA verification, so the "tfa" requirement means the account has to enable two-factor authentication. Label it that way in the reauthorization prompt, start the integration server with organization 2FA policies enabled, and cover both an unenrolled member seeing the requirement and an enrolled member keeping organization access when a policy starts. --- lib/hex/remote_converger.ex | 2 +- test/hex/auth_test.exs | 2 +- .../organization_auth_integration_test.exs | 51 +++++-------------- test/support/hexpm.ex | 3 +- 4 files changed, 18 insertions(+), 40 deletions(-) diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index bc7ceb2f..dffecaa7 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -1058,7 +1058,7 @@ defmodule Hex.RemoteConverger do Enum.map_join(entries, "; ", fn entry -> reasons = Enum.map_join(entry.requirements, ", ", fn - "tfa" -> "2FA verification required" + "tfa" -> "2FA enrollment required" "sso" -> "SSO authentication required" end) diff --git a/test/hex/auth_test.exs b/test/hex/auth_test.exs index c301ed53..22d4f351 100644 --- a/test/hex/auth_test.exs +++ b/test/hex/auth_test.exs @@ -399,7 +399,7 @@ defmodule Hex.AuthTest do send(self(), {:mix_shell_input, :yes?, false}) check_organization_reauth([{"hexpm:acme", "foo"}]) assert_received {:mix_shell, :yes?, [question]} - assert question =~ "acme: 2FA verification required, SSO authentication required" + assert question =~ "acme: 2FA enrollment required, SSO authentication required" assert Hex.OAuth.organization_reauth_required() == entries end) end diff --git a/test/hex/organization_auth_integration_test.exs b/test/hex/organization_auth_integration_test.exs index a24aa140..5b0de525 100644 --- a/test/hex/organization_auth_integration_test.exs +++ b/test/hex/organization_auth_integration_test.exs @@ -1,7 +1,7 @@ defmodule Hex.OrganizationAuthIntegrationTest do use HexTest.IntegrationCase, async: false - test "the changed server filters enforcing organizations on refresh and Hex retains 2FA reasons" do + 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]) @@ -32,16 +32,16 @@ defmodule Hex.OrganizationAuthIntegrationTest do send(self(), {:mix_shell_input, :yes?, false}) Hex.RemoteConverger.check_organization_reauth([enforcing, unaffected]) assert_received {:mix_shell, :yes?, [question]} - assert question =~ "#{enforcing}: 2FA verification required" + assert question =~ "#{enforcing}: 2FA enrollment required" refute question =~ "SSO authentication required" end - test "an accepted API OTP restores organization access without another authentication prompt" do - in_tmp("organization_tfa_write_client_review", fn -> + 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 = "review_tfa_#{suffix}" - organization = "review_tfa_org_#{suffix}" + username = "enrolled_tfa_#{suffix}" + organization = "enrolled_tfa_org_#{suffix}" secret = "JBSWY3DPEHPK3PXP" config = Hex.API.Client.config() @@ -59,49 +59,26 @@ defmodule Hex.OrganizationAuthIntegrationTest do "scope" => "api repositories" }) - Hex.OAuth.store_token(%{ - access_token: response["access_token"], - refresh_token: response["refresh_token"], - expires_at: System.system_time(:second) + response["expires_in"] - }) - auth = [key: response["access_token"], oauth: true, otp: totp(secret)] assert {:ok, {204, _, _}} = Hexpm.new_repo(organization, auth) - Hexpm.new_package(organization, "review_tfa_pkg", "1.0.0", [], %{}, auth) assert {:ok, {204, _, _}} = :mix_hex_api.post(Hex.API.Client.config(auth), ["organization_tfa"], %{ "organization" => organization }) - assert {:ok, {200, _, fresh_session}} = - :mix_hex_api.post(config, ["oauth_token"], %{ - "username" => username, - "scope" => "api repositories" - }) - Hex.OAuth.store_token(%{ - access_token: fresh_session["access_token"], - refresh_token: fresh_session["refresh_token"], - expires_at: System.system_time(:second) + fresh_session["expires_in"] + 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 [%{organization: ^organization, requirements: ["tfa"]}] = - Hex.OAuth.organization_reauth_required() - - Hex.State.put(:api_otp, nil) - send(self(), {:mix_shell_input, :prompt, totp(secret)}) - Mix.Tasks.Hex.Owner.run(["add", "review_tfa_pkg", username, "--organization", organization]) - output = flush() - assert Enum.any?(output, &match?({:mix_shell, :prompt, ["Enter OTP code:"]}, &1)) - refute Enum.any?(output, &match?({:mix_shell, :yes?, _}, &1)) - refute Enum.any?(output, &match?({:mix_shell, :error, ["Adding owner failed"]}, &1)) assert :ok = Hex.Auth.refresh_tokens(Hex.API.Client.config()) assert Hex.OAuth.organization_reauth_required() == [] - assert {:ok, {200, _, owners}} = Hex.API.Package.Owner.get(organization, "review_tfa_pkg") - assert Enum.any?(owners, &(&1["username"] == username)) + 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 @@ -109,7 +86,7 @@ defmodule Hex.OrganizationAuthIntegrationTest 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-size(offset), code::32, _::binary>> = digest + <<_::binary-size(^offset), code::32, _::binary>> = digest code |> Bitwise.band(0x7FFFFFFF) 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 -> From 03ffa10b2bc3f2f5dda5bb685403b8b40ed4a79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 14:56:43 +0200 Subject: [PATCH 4/6] Read the TOTP dynamic offset without a pinned binary size --- test/hex/organization_auth_integration_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hex/organization_auth_integration_test.exs b/test/hex/organization_auth_integration_test.exs index 5b0de525..da83d894 100644 --- a/test/hex/organization_auth_integration_test.exs +++ b/test/hex/organization_auth_integration_test.exs @@ -86,7 +86,7 @@ defmodule Hex.OrganizationAuthIntegrationTest 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-size(^offset), code::32, _::binary>> = digest + <> = :binary.part(digest, offset, 4) code |> Bitwise.band(0x7FFFFFFF) From 71d52fdbc942c3aed2e0ae45068f22e362219209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 14 Sep 2026 16:18:26 +0200 Subject: [PATCH 5/6] Refresh after the authorization prompt regardless of elapsed time The server decides whether a browser request was completed, so the client no longer skips the refresh when the verification URL's expiry has passed by the time the user presses Enter. Re-vendor hex_core from 68d8345 for the improper requirement list fix. --- lib/hex/remote_converger.ex | 16 ++++++---------- src/mix_hex_advisory.erl | 2 +- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 17 ++++++++++++++--- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_cli_auth.erl | 2 +- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_policy.erl | 2 +- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 2 +- src/mix_hex_repo.erl | 2 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- test/hex/auth_test.exs | 24 ++++++++++++++++++++++-- 32 files changed, 71 insertions(+), 44 deletions(-) diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index dffecaa7..8aa49f05 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -1000,20 +1000,16 @@ defmodule Hex.RemoteConverger do defp start_organization_reauth(entries) do case Hex.API.OAuth.organization_authorization(Enum.map(entries, & &1.organization)) do - {:ok, {status, _, %{"verification_uri" => uri, "expires_in" => expires_in}}} - when status in 200..299 and is_binary(uri) and is_integer(expires_in) and expires_in > 0 -> + {:ok, {status, _, %{"verification_uri" => uri}}} + when status in 200..299 and is_binary(uri) -> uri = Hex.Utils.printable_ascii(uri) - expires_at = System.monotonic_time(:second) + expires_in open_browser(uri) + # 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) -> - if System.monotonic_time(:second) < expires_at, - do: finish_organization_reauth(entries), - else: unavailable(entries, "the request expired") - - _ -> - unavailable(entries, "authentication was cancelled") + answer when is_binary(answer) -> finish_organization_reauth(entries) + _ -> unavailable(entries, "authentication was cancelled") end {:ok, {_status, _, %{"message" => message}}} when is_binary(message) -> diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 3d8cb997..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 (b3757c5), 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 94494ec9..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 (b3757c5), 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 d03a43e4..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 (b3757c5), 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 4ab37357..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 (b3757c5), 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 cc557021..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 (b3757c5), do not edit manually +%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually %% @doc %% Hex HTTP API - OAuth. @@ -421,8 +421,8 @@ parse_organization_requirements( #{<<"organization">> := Name, <<"requirements">> := Requirements} | Rest ], Acc -) when is_binary(Name), byte_size(Name) > 0, is_list(Requirements), Requirements =/= [] -> - case lists:all(fun(R) -> R =:= <<"tfa">> orelse R =:= <<"sso">> end, Requirements) of +) when is_binary(Name), byte_size(Name) > 0, Requirements =/= [] -> + case valid_requirements(Requirements) of true -> parse_organization_requirements( Rest, @@ -434,6 +434,17 @@ parse_organization_requirements( 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 %%==================================================================== diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index fcc6bb15..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 (b3757c5), 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 ae09bd07..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 (b3757c5), 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 8c2913cb..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 (b3757c5), 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 4349108b..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 (b3757c5), 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 4a0bb627..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 (b3757c5), 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 5cace74e..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 (b3757c5), 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 6eef2b8d..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 (b3757c5), 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 d2348c48..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 (b3757c5), 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. diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index 729c317f..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 (b3757c5), 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 64086544..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 (b3757c5), 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 002fa2b3..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 (b3757c5), 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 393a4afd..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 (b3757c5), 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 ffc0b479..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 (b3757c5), 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 e6a58528..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 (b3757c5), 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 2f285424..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 (b3757c5), 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 31517a10..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 (b3757c5), 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 c86078ec..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 (b3757c5), 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 a087c0fd..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 (b3757c5), 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 5eec697c..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 (b3757c5), 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 f82bedde..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 (b3757c5), 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 3461d01d..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 (b3757c5), 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 9c5e22bb..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 (b3757c5), 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 3a930d82..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 (b3757c5), 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 65690d1f..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 (b3757c5), 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 eaaeac1e..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 (b3757c5), 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 22d4f351..ddafa277 100644 --- a/test/hex/auth_test.exs +++ b/test/hex/auth_test.exs @@ -277,6 +277,22 @@ defmodule Hex.AuthTest do 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("organization_reauth", fn -> set_home_cwd() @@ -584,7 +600,11 @@ defmodule Hex.AuthTest do # 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_organization_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() @@ -604,7 +624,7 @@ defmodule Hex.AuthTest do erlang_resp(conn, 201, %{ "verification_uri" => uri, - "expires_in" => 600 + "expires_in" => expires_in }) "/api/oauth/token" -> From d2ae656fc50232b5a54fa3cdd3ca701a3785ce4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 15 Sep 2026 15:52:10 +0200 Subject: [PATCH 6/6] Run integration tests against hexpm main again --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9e5384a5..24f84308 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,7 +68,7 @@ jobs: env: HEXPM_OTP: OTP-28.5 HEXPM_ELIXIR: v1.19.5 - HEXPM_BRANCH: organization-tfa-enforcement + HEXPM_BRANCH: main HEXPM_PATH: hexpm HEXPM_ELIXIR_PATH: hexpm_elixir HEXPM_OTP_PATH: hexpm_otp