Skip to content

Commit 0d5d10d

Browse files
authored
Limit deps.get auth preflight and trust-gate user OAuth fallback (#1145)
1 parent 8bb9ef5 commit 0d5d10d

5 files changed

Lines changed: 227 additions & 39 deletions

File tree

lib/hex/remote_converger.ex

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,6 @@ defmodule Hex.RemoteConverger do
3131

3232
Registry.open()
3333

34-
# Check and refresh OAuth token before fetching packages
35-
# This ensures we only prompt once for authentication instead of
36-
# getting multiple prompts during concurrent package fetches
37-
check_and_refresh_auth()
38-
3934
# We cannot use given lock here, because all deps that are being
4035
# converged have been removed from the lock by Mix
4136
# We need the old lock to get the children of Hex packages
@@ -44,14 +39,20 @@ defmodule Hex.RemoteConverger do
4439
overridden = Hex.Mix.overridden_deps(deps)
4540
requests = Hex.Mix.deps_to_requests(deps)
4641

47-
[
48-
Hex.Mix.packages_from_lock(lock),
49-
Hex.Mix.packages_from_lock(old_lock),
50-
packages_from_requests(requests)
51-
]
52-
|> Enum.concat()
53-
|> verify_prefetches()
54-
|> Registry.prefetch()
42+
prefetches =
43+
[
44+
Hex.Mix.packages_from_lock(lock),
45+
Hex.Mix.packages_from_lock(old_lock),
46+
packages_from_requests(requests)
47+
]
48+
|> Enum.concat()
49+
|> verify_prefetches()
50+
51+
# Only preflight user OAuth when one of the relevant repos would
52+
# actually rely on the stored OAuth token. Public-only deps.get
53+
# should not prompt just because an unrelated token expired.
54+
check_and_refresh_auth(prefetches)
55+
Registry.prefetch(prefetches)
5556

5657
locked = prepare_locked(lock, old_lock, deps)
5758

@@ -734,34 +735,60 @@ defmodule Hex.RemoteConverger do
734735
version1.major == 0 and version2.major == 0 and version1.minor != version2.minor
735736
end
736737

737-
defp check_and_refresh_auth do
738-
# Try to get token with authentication prompting enabled
739-
# The OnceCache ensures only one process prompts even if multiple processes
740-
# detect the expired token concurrently
741-
case Hex.OAuth.get_token(prompt_auth: true) do
742-
{:ok, _access_token} ->
743-
# Token is valid, was successfully refreshed, or user authenticated
744-
:ok
738+
defp check_and_refresh_auth(prefetches) do
739+
if auth_preflight_required?(prefetches) do
740+
# Try to get token with authentication prompting enabled
741+
# The OnceCache ensures only one process prompts even if multiple processes
742+
# detect the expired token concurrently
743+
case Hex.OAuth.get_token(prompt_auth: true) do
744+
{:ok, _access_token} ->
745+
# Token is valid, was successfully refreshed, or user authenticated
746+
:ok
745747

746-
{:error, :auth_failed} ->
747-
Hex.Shell.warn(
748-
"Authentication failed. Private packages will not be available. " <>
749-
"Run `mix hex.user auth` to authenticate."
750-
)
748+
{:error, :auth_failed} ->
749+
Hex.Shell.warn(
750+
"Authentication failed. Private packages will not be available. " <>
751+
"Run `mix hex.user auth` to authenticate."
752+
)
751753

752-
{:error, :auth_declined} ->
753-
Hex.Shell.warn(
754-
"Private packages will not be available. " <>
755-
"Run `mix hex.user auth` to authenticate."
756-
)
754+
{:error, :auth_declined} ->
755+
Hex.Shell.warn(
756+
"Private packages will not be available. " <>
757+
"Run `mix hex.user auth` to authenticate."
758+
)
757759

758-
{:error, :no_auth} ->
759-
# No OAuth token - this is OK, user might only be fetching public packages
760-
:ok
760+
{:error, :no_auth} ->
761+
# No OAuth token - this is OK, user might only be fetching public packages
762+
:ok
761763

762-
{:error, _other} ->
763-
# Other errors (shouldn't happen with prompt_auth: true, but handle gracefully)
764-
:ok
764+
{:error, _other} ->
765+
# Other errors (shouldn't happen with prompt_auth: true, but handle gracefully)
766+
:ok
767+
end
768+
else
769+
:ok
765770
end
766771
end
772+
773+
@doc false
774+
def auth_preflight_required?(prefetches) do
775+
prefetches
776+
|> Enum.map(fn {repo, _package} -> repo end)
777+
|> Enum.uniq()
778+
|> Enum.any?(&repo_requires_user_oauth?/1)
779+
end
780+
781+
defp repo_requires_user_oauth?("hexpm:" <> _ = repo) do
782+
repo
783+
|> Hex.Repo.get_repo()
784+
|> repo_uses_user_oauth?()
785+
end
786+
787+
defp repo_requires_user_oauth?(_repo) do
788+
false
789+
end
790+
791+
defp repo_uses_user_oauth?(repo_config) do
792+
Map.get(repo_config, :trusted, true) && !repo_config.auth_key
793+
end
767794
end

lib/hex/repo.ex

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,10 @@ defmodule Hex.Repo do
331331
raise "Failed to exchange API key for OAuth token: #{inspect(reason)}"
332332
end
333333

334-
# Third priority: fallback to OAuth token if available (from device flow or other sources)
335-
true ->
334+
# Third priority: fallback to OAuth token if available, but only for
335+
# trusted repos. Untrusted mirrors/custom repos must not receive the
336+
# user bearer token.
337+
Map.get(repo_config, :trusted, true) ->
336338
case Hex.OAuth.get_token() do
337339
{:ok, access_token} ->
338340
# Format as Bearer token for OAuth authentication
@@ -343,6 +345,9 @@ defmodule Hex.Repo do
343345
# Server will return 401/403 if authentication is required
344346
config
345347
end
348+
349+
true ->
350+
config
346351
end
347352

348353
if etag do

test/hex/remote_converger_test.exs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,61 @@ defmodule Hex.RemoteConvergerTest do
6161
end
6262
end
6363

64+
defmodule PublicDepsWithExpiredOAuth.MixProject do
65+
def project do
66+
[
67+
app: :public_deps_with_expired_oauth,
68+
version: "0.1.0",
69+
deps: [
70+
{:postgrex, "0.2.1"}
71+
]
72+
]
73+
end
74+
end
75+
76+
defmodule OrganizationDepsWithExpiredOAuth.MixProject do
77+
def project do
78+
[
79+
app: :organization_deps_with_expired_oauth,
80+
version: "0.1.0",
81+
deps: [
82+
{:private_prompt_pkg, "0.1.0", repo: "hexpm:remote_converger_org"}
83+
]
84+
]
85+
end
86+
end
87+
88+
defp with_project(project, fun) do
89+
Mix.Project.push(project)
90+
91+
try do
92+
fun.()
93+
after
94+
Mix.Project.pop()
95+
end
96+
end
97+
98+
defp store_expired_oauth_token do
99+
Hex.OnceCache.clear(Hex.OAuth.RefreshCache)
100+
101+
Hex.OAuth.store_token(%{
102+
"access_token" => "expired_access_token",
103+
"refresh_token" => "invalid_refresh_token",
104+
"expires_at" => System.system_time(:second) - 3600
105+
})
106+
end
107+
108+
defp new_repo_auth_user(prefix) do
109+
suffix = System.unique_integer([:positive])
110+
111+
Hexpm.new_user(
112+
"#{prefix}_#{suffix}",
113+
"#{prefix}_#{suffix}@mail.com",
114+
"password",
115+
"#{prefix}_#{suffix}_key"
116+
)
117+
end
118+
64119
test "deps with warn_if_outdated: true and hex: option" do
65120
in_tmp(fn ->
66121
Mix.Project.push(WarnOutdatedWithHexOption.MixProject)
@@ -69,4 +124,78 @@ defmodule Hex.RemoteConvergerTest do
69124
assert :ok = Mix.Tasks.Deps.Get.run([])
70125
end)
71126
end
127+
128+
test "deps.get does not prompt for auth when only public deps are requested" do
129+
in_tmp(fn ->
130+
set_home_cwd()
131+
store_expired_oauth_token()
132+
133+
with_project(PublicDepsWithExpiredOAuth.MixProject, fn ->
134+
assert :ok = Mix.Tasks.Deps.Get.run([])
135+
end)
136+
137+
refute_received {:mix_shell, :yes?, _}
138+
end)
139+
end
140+
141+
test "auth preflight is skipped for public repos" do
142+
assert Hex.RemoteConverger.auth_preflight_required?([{"hexpm", "postgrex"}]) == false
143+
end
144+
145+
test "auth preflight is required for organization repos without repo auth" do
146+
in_tmp(fn ->
147+
set_home_cwd()
148+
149+
assert Hex.RemoteConverger.auth_preflight_required?([
150+
{"hexpm:remote_converger_org", "private_prompt_pkg"}
151+
])
152+
end)
153+
end
154+
155+
test "auth preflight is skipped when repo auth is already available" do
156+
in_tmp(fn ->
157+
set_home_cwd()
158+
159+
auth = new_repo_auth_user("remote_converger_repo_auth_preflight")
160+
161+
repos = Hex.State.fetch!(:repos)
162+
repos = put_in(repos["hexpm"].auth_key, auth[:key])
163+
Hex.State.put(:repos, repos)
164+
165+
assert Hex.RemoteConverger.auth_preflight_required?([
166+
{"hexpm:remote_converger_org", "private_prompt_pkg"}
167+
]) == false
168+
end)
169+
end
170+
171+
test "auth preflight is skipped for untrusted org repos" do
172+
in_tmp(fn ->
173+
set_home_cwd()
174+
Hex.State.put(:mirror_url, "http://example.com")
175+
176+
assert Hex.RemoteConverger.auth_preflight_required?([
177+
{"hexpm:remote_converger_org", "private_prompt_pkg"}
178+
]) == false
179+
end)
180+
end
181+
182+
test "deps.get does not prompt when repo auth is already available" do
183+
in_tmp(fn ->
184+
set_home_cwd()
185+
186+
auth = new_repo_auth_user("remote_converger_repo_auth_deps_get")
187+
188+
repos = Hex.State.fetch!(:repos)
189+
repos = put_in(repos["hexpm"].auth_key, auth[:key])
190+
Hex.State.put(:repos, repos)
191+
192+
store_expired_oauth_token()
193+
194+
with_project(OrganizationDepsWithExpiredOAuth.MixProject, fn ->
195+
assert :ok = Mix.Tasks.Deps.Get.run([])
196+
end)
197+
198+
refute_received {:mix_shell, :yes?, _}
199+
end)
200+
end
72201
end

test/hex/repo_test.exs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ defmodule Hex.RepoTest do
5656
assert {:ok, {404, _, "not found"}} = Hex.Repo.get_public_key(config)
5757
end
5858

59+
test "does not send OAuth token fallback to untrusted hexpm mirror" do
60+
bypass = Bypass.open()
61+
hexpm = Hex.Repo.default_hexpm_repo()
62+
63+
Hex.OAuth.store_token(%{
64+
"access_token" => "device_flow_token",
65+
"refresh_token" => "device_refresh",
66+
"expires_at" => System.system_time(:second) + 3600
67+
})
68+
69+
Hex.State.put(:mirror_url, "http://localhost:#{bypass.port}")
70+
71+
Bypass.expect(bypass, fn %Plug.Conn{request_path: "/public_key"} = conn ->
72+
assert Plug.Conn.get_req_header(conn, "authorization") == []
73+
Plug.Conn.resp(conn, 200, hexpm.public_key)
74+
end)
75+
76+
repo = Hex.Repo.get_repo("hexpm")
77+
assert repo.trusted == false
78+
79+
assert {:ok, {200, _, public_key}} = Hex.Repo.get_public_key(repo)
80+
assert public_key == hexpm.public_key
81+
end
82+
5983
test "add repo persists oauth_exchange through config round-trip" do
6084
in_tmp(fn ->
6185
Hex.State.put(:config_home, File.cwd!())

test/setup_hexpm.exs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ Hexpm.new_repo("testorg", auth)
8989
Hexpm.new_package("testorg", "foo", "0.1.0", [], pkg_meta, auth)
9090
Hexpm.new_package("testorg", "bar", "0.1.0", [foo: "~> 0.1.0"], pkg_meta, auth)
9191

92+
Hexpm.new_repo("remote_converger_org", auth)
93+
Hexpm.new_package("remote_converger_org", "private_prompt_pkg", "0.1.0", [], pkg_meta, auth)
94+
9295
Hexpm.new_package("hexpm", "ecto_sql", "3.3.2", [ecto: "~> 3.3.1"], pkg_meta, auth, [
9396
{"mix.exs", File.read!(Case.fixture_path("ecto_sql_3_3_2/mix.exs"))}
9497
])

0 commit comments

Comments
 (0)