diff --git a/lib/hex/api/auth.ex b/lib/hex/api/auth.ex index 3313bb12..325eb7a6 100644 --- a/lib/hex/api/auth.ex +++ b/lib/hex/api/auth.ex @@ -3,7 +3,7 @@ defmodule Hex.API.Auth do alias Hex.API.Client - def get(domain, resource, auth) do + def get(domain, resource, auth \\ []) do config = Client.config(auth) params = %{ @@ -11,6 +11,9 @@ defmodule Hex.API.Auth do resource: to_string(resource) } - :mix_hex_api_auth.test(config, params) + Hex.Auth.with_api(:read, config, &:mix_hex_api_auth.test(&1, params), + auth_inline: false, + optional: true + ) end end diff --git a/lib/hex/api/client.ex b/lib/hex/api/client.ex index b4b7b79e..4c3d48bc 100644 --- a/lib/hex/api/client.ex +++ b/lib/hex/api/client.ex @@ -6,7 +6,8 @@ defmodule Hex.API.Client do :mix_hex_core.default_config() | http_adapter: {Hex.HTTP, %{}}, api_url: Hex.State.fetch!(:api_url), - http_user_agent_fragment: user_agent_fragment() + http_user_agent_fragment: user_agent_fragment(), + cli_auth_callbacks: Hex.Auth.callbacks() } config @@ -26,9 +27,8 @@ defmodule Hex.API.Client do opts[:user] && opts[:pass] -> # For basic auth, add it as an HTTP header base64 = Base.encode64("#{opts[:user]}:#{opts[:pass]}") - headers = Map.get(config, :http_headers, %{}) - headers = Map.put(headers, "authorization", "Basic #{base64}") - Map.put(config, :http_headers, headers) + token = "Basic #{base64}" + Map.put(config, :api_key, token) true -> config @@ -36,7 +36,7 @@ defmodule Hex.API.Client do end defp maybe_put_otp(config, opts) do - if otp = opts[:otp] do + if otp = opts[:otp] || Hex.State.get(:api_otp) do Map.put(config, :api_otp, otp) else config diff --git a/lib/hex/api/key.ex b/lib/hex/api/key.ex index 3d421732..994731ff 100644 --- a/lib/hex/api/key.ex +++ b/lib/hex/api/key.ex @@ -3,37 +3,34 @@ defmodule Hex.API.Key do alias Hex.API.Client - def new(name, permissions, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.config(auth_with_otp) + def new(name, permissions, auth \\ []) do + config = Client.config(auth) - # Convert permissions to binary map format expected by hex_core - permissions = - Enum.map(permissions, fn perm -> - Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end) - end) + # Convert permissions to binary map format expected by hex_core + permissions = + Enum.map(permissions, fn perm -> + Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end) + end) - :mix_hex_api_key.add(config, to_string(name), permissions) - end) + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.add(&1, to_string(name), permissions)) end - def get(auth) do + def get(auth \\ []) do config = Client.config(auth) - :mix_hex_api_key.list(config) + + Hex.Auth.with_api(:read, config, &:mix_hex_api_key.list(&1)) end - def delete(name, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.config(auth_with_otp) - :mix_hex_api_key.delete(config, to_string(name)) - end) + def delete(name, auth \\ []) do + config = Client.config(auth) + + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete(&1, to_string(name))) end - def delete_all(auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.config(auth_with_otp) - :mix_hex_api_key.delete_all(config) - end) + def delete_all(auth \\ []) do + config = Client.config(auth) + + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete_all(&1)) end defmodule Organization do @@ -41,42 +38,35 @@ defmodule Hex.API.Key do alias Hex.API.Client - def new(organization, name, permissions, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = - Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization))) + def new(organization, name, permissions, auth \\ []) do + config = + Client.config(Keyword.put(auth, :api_organization, to_string(organization))) - # Convert permissions to binary map format expected by hex_core - permissions = - Enum.map(permissions, fn perm -> - Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end) - end) + # Convert permissions to binary map format expected by hex_core + permissions = + Enum.map(permissions, fn perm -> + Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end) + end) - :mix_hex_api_key.add(config, to_string(name), permissions) - end) + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.add(&1, to_string(name), permissions)) end - def get(organization, auth) do + def get(organization, auth \\ []) do config = Client.config(Keyword.put(auth, :api_organization, to_string(organization))) - :mix_hex_api_key.list(config) + Hex.Auth.with_api(:read, config, &:mix_hex_api_key.list(&1)) end - def delete(organization, name, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = - Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization))) + def delete(organization, name, auth \\ []) do + config = + Client.config(Keyword.put(auth, :api_organization, to_string(organization))) - :mix_hex_api_key.delete(config, to_string(name)) - end) + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete(&1, to_string(name))) end - def delete_all(organization, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = - Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization))) + def delete_all(organization, auth \\ []) do + config = Client.config(Keyword.put(auth, :api_organization, to_string(organization))) - :mix_hex_api_key.delete_all(config) - end) + Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete_all(&1)) end end end diff --git a/lib/hex/api/oauth.ex b/lib/hex/api/oauth.ex index c786ad0d..be7534f8 100644 --- a/lib/hex/api/oauth.ex +++ b/lib/hex/api/oauth.ex @@ -5,99 +5,42 @@ defmodule Hex.API.OAuth do @client_id "78ea6566-89fd-481e-a1d6-7d9d78eacca8" - @doc """ - Initiates the OAuth device authorization flow. - - Returns device code, user code, and verification URIs for user authentication. - Optionally accepts a name parameter to identify the token. - - ## Examples - - iex> Hex.API.OAuth.device_authorization("api") - {:ok, {200, _headers, %{ - "device_code" => "...", - "user_code" => "ABCD-1234", - "verification_uri" => "https://hex.pm/oauth/device", - "verification_uri_complete" => "https://hex.pm/oauth/device?user_code=ABCD-1234", - "expires_in" => 600, - "interval" => 5 - }}} - """ - def device_authorization(scopes, name \\ nil) do - config = Client.config() - opts = if name, do: [name: name], else: [] - :mix_hex_api_oauth.device_authorization(config, @client_id, scopes, opts) - end - - @doc """ - Polls the OAuth token endpoint for device authorization completion. - - ## Examples - - iex> Hex.API.OAuth.poll_device_token(device_code) - {:ok, {200, _headers, %{ - "access_token" => "...", - "refresh_token" => "...", - "token_type" => "Bearer", - "expires_in" => 3600 - }}} - """ - def poll_device_token(device_code) do - config = Client.config() - :mix_hex_api_oauth.poll_device_token(config, @client_id, device_code) - end + @doc false + def client_id, do: @client_id @doc """ - Refreshes an access token using a refresh token. + Runs the complete OAuth device authorization flow. - ## Examples - - iex> Hex.API.OAuth.refresh_token(refresh_token) - {:ok, {200, _headers, %{ - "access_token" => "...", - "refresh_token" => "...", - "token_type" => "Bearer", - "expires_in" => 3600 - }}} - """ - def refresh_token(refresh_token) do - config = Client.config() - :mix_hex_api_oauth.refresh_token(config, @client_id, refresh_token) - end - - @doc """ - Exchanges an API key for a short-lived OAuth access token using the client credentials grant. - - Optionally accepts a custom API URL for the OAuth exchange endpoint. + See `:mix_hex_api_oauth.device_auth_flow/5` for more details. ## Examples - iex> Hex.API.OAuth.exchange_api_key(api_key, "api") - {:ok, {200, _headers, %{ - "access_token" => "...", - "token_type" => "bearer", - "expires_in" => 1800, - "scope" => "api" - }}} + iex> prompt_fn = fn uri, code -> IO.puts("Visit \#{uri} and enter: \#{code}") end + iex> Hex.API.OAuth.device_auth_flow("api", prompt_fn) + {:ok, %{access_token: "...", refresh_token: "...", expires_at: 1234567890}} - iex> Hex.API.OAuth.exchange_api_key(api_key, "api", nil, "https://custom.hex.pm") - {:ok, {200, _headers, %{...}}} + iex> Hex.API.OAuth.device_auth_flow("api", prompt_fn, open_browser: true) + {:ok, %{access_token: "...", refresh_token: "...", expires_at: 1234567890}} """ - def exchange_api_key(api_key, scopes, name \\ nil, api_url \\ nil) do + def device_auth_flow(scopes, prompt_user, opts \\ []) do config = Client.config() - config = - if api_url do - Map.put(config, :api_url, api_url) - else - config - end + case :mix_hex_api_oauth.device_auth_flow(config, @client_id, scopes, prompt_user, opts) do + {:ok, tokens} -> {:ok, drop_undefined_refresh_token(tokens)} + other -> other + end + end - scope_string = if is_list(scopes), do: Enum.join(scopes, " "), else: scopes - opts = if name, do: [name: name], else: [] - :mix_hex_api_oauth.client_credentials_token(config, @client_id, api_key, scope_string, opts) + # :mix_hex_api_oauth always includes a :refresh_token key, using the atom + # :undefined when the server didn't return one. Drop it so stored token maps + # only ever contain a binary refresh token (or no key at all). + defp drop_undefined_refresh_token(%{refresh_token: refresh} = tokens) + when refresh in [:undefined, nil] do + Map.delete(tokens, :refresh_token) end + defp drop_undefined_refresh_token(tokens), do: tokens + @doc """ Revokes an OAuth token (access or refresh token). diff --git a/lib/hex/api/package.ex b/lib/hex/api/package.ex index a950dfac..ae0bdb5a 100644 --- a/lib/hex/api/package.ex +++ b/lib/hex/api/package.ex @@ -5,14 +5,27 @@ defmodule Hex.API.Package do def get(repo, name, auth \\ []) when name != "" do config = Client.build_config(repo, auth) - :mix_hex_api_package.get(config, to_string(name)) + + Hex.Auth.with_api( + :read, + config, + &:mix_hex_api_package.get(&1, to_string(name)), + auth_inline: false, + optional: true + ) end def search(repo, search, auth \\ []) do config = Client.build_config(repo, auth) search_params = [{:sort, "downloads"}] - :mix_hex_api_package.search(config, to_string(search), search_params) + Hex.Auth.with_api( + :read, + config, + &:mix_hex_api_package.search(&1, to_string(search), search_params), + auth_inline: false, + optional: true + ) end defmodule Owner do @@ -20,35 +33,47 @@ defmodule Hex.API.Package do alias Hex.API.Client - def add(repo, package, owner, level, transfer, auth) when package != "" do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def add(repo, package, owner, level, transfer, auth \\ []) when package != "" do + config = + Client.build_config(repo, auth) - :mix_hex_api_package_owner.add( - config, + Hex.Auth.with_api( + :write, + config, + &:mix_hex_api_package_owner.add( + &1, to_string(package), to_string(owner), to_string(level), transfer ) - end) + ) end - def delete(repo, package, owner, auth) when package != "" do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def delete(repo, package, owner, auth \\ []) when package != "" do + config = Client.build_config(repo, auth) - :mix_hex_api_package_owner.delete( - config, + Hex.Auth.with_api( + :write, + config, + &:mix_hex_api_package_owner.delete( + &1, to_string(package), to_string(owner) ) - end) + ) end - def get(repo, package, auth) when package != "" do + def get(repo, package, auth \\ []) when package != "" do config = Client.build_config(repo, auth) - :mix_hex_api_package_owner.list(config, to_string(package)) + + Hex.Auth.with_api( + :read, + config, + &:mix_hex_api_package_owner.list(&1, to_string(package)), + auth_inline: false, + optional: true + ) end end end diff --git a/lib/hex/api/release.ex b/lib/hex/api/release.ex index c4d2ea85..29f2e1ff 100644 --- a/lib/hex/api/release.ex +++ b/lib/hex/api/release.ex @@ -6,47 +6,59 @@ defmodule Hex.API.Release do def get(repo, name, version, auth \\ []) do config = Client.build_config(repo, auth) - :mix_hex_api_release.get(config, to_string(name), to_string(version)) + Hex.Auth.with_api( + :read, + config, + &:mix_hex_api_release.get(&1, to_string(name), to_string(version)), + auth_inline: false, + optional: true + ) end - def publish(repo, tar, auth, progress \\ fn _ -> nil end, replace \\ false) + def publish(repo, tar, auth \\ [], progress \\ fn _ -> nil end, replace \\ false) def publish(repo, tar, auth, progress, replace?) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + config = Client.build_config(repo, auth) + # Pass progress callback through adapter config + adapter_config = %{progress_callback: progress} - # Pass progress callback through adapter config - adapter_config = %{progress_callback: progress} + Hex.Auth.with_api(:write, config, fn config -> config = Map.put(config, :http_adapter, {Hex.HTTP, adapter_config}) - params = [{:replace, replace?}] - :mix_hex_api_release.publish(config, tar, params) + :mix_hex_api_release.publish(config, tar, replace: replace?) end) end - def delete(repo, name, version, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def delete(repo, name, version, auth \\ []) do + config = Client.build_config(repo, auth) - :mix_hex_api_release.delete(config, to_string(name), to_string(version)) - end) + Hex.Auth.with_api( + :write, + config, + &:mix_hex_api_release.delete(&1, to_string(name), to_string(version)) + ) end - def retire(repo, name, version, body, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) - # Convert body to binary map for hex_core - params = Map.new(body, fn {k, v} -> {to_string(k), to_string(v)} end) + def retire(repo, name, version, body, auth \\ []) do + config = Client.build_config(repo, auth) + + # Convert body to binary map for hex_core + params = Map.new(body, fn {k, v} -> {to_string(k), to_string(v)} end) - :mix_hex_api_release.retire(config, to_string(name), to_string(version), params) - end) + Hex.Auth.with_api( + :write, + config, + &:mix_hex_api_release.retire(&1, to_string(name), to_string(version), params) + ) end - def unretire(repo, name, version, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def unretire(repo, name, version, auth \\ []) do + config = Client.build_config(repo, auth) - :mix_hex_api_release.unretire(config, to_string(name), to_string(version)) - end) + Hex.Auth.with_api( + :write, + config, + &:mix_hex_api_release.unretire(&1, to_string(name), to_string(version)) + ) end end diff --git a/lib/hex/api/release_docs.ex b/lib/hex/api/release_docs.ex index a5d198e3..9dbec0c3 100644 --- a/lib/hex/api/release_docs.ex +++ b/lib/hex/api/release_docs.ex @@ -15,25 +15,28 @@ defmodule Hex.API.ReleaseDocs do "docs" ]) - :mix_hex_api.get(config, path) + Hex.Auth.with_api(:read, config, &:mix_hex_api.get(&1, path), + auth_inline: false, + optional: true + ) end - def publish(repo, name, version, tar, auth, progress \\ fn _ -> nil end) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def publish(repo, name, version, tar, auth \\ [], progress \\ fn _ -> nil end) do + config = Client.build_config(repo, auth) + # Pass progress callback through adapter config + adapter_config = %{progress_callback: progress} - # Pass progress callback through adapter config - adapter_config = %{progress_callback: progress} - config = Map.put(config, :http_adapter, {Hex.HTTP, adapter_config}) + path = + :mix_hex_api.build_repository_path(config, [ + "packages", + to_string(name), + "releases", + to_string(version), + "docs" + ]) - path = - :mix_hex_api.build_repository_path(config, [ - "packages", - to_string(name), - "releases", - to_string(version), - "docs" - ]) + Hex.Auth.with_api(:write, config, fn config -> + config = Map.put(config, :http_adapter, {Hex.HTTP, adapter_config}) body = {"application/octet-stream", tar} @@ -41,20 +44,18 @@ defmodule Hex.API.ReleaseDocs do end) end - def delete(repo, name, version, auth) do - Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp -> - config = Client.build_config(repo, auth_with_otp) + def delete(repo, name, version, auth \\ []) do + config = Client.build_config(repo, auth) - path = - :mix_hex_api.build_repository_path(config, [ - "packages", - to_string(name), - "releases", - to_string(version), - "docs" - ]) + path = + :mix_hex_api.build_repository_path(config, [ + "packages", + to_string(name), + "releases", + to_string(version), + "docs" + ]) - :mix_hex_api.delete(config, path) - end) + Hex.Auth.with_api(:write, config, &:mix_hex_api.delete(&1, path)) end end diff --git a/lib/hex/api/user.ex b/lib/hex/api/user.ex index 4ab98c31..76e2e808 100644 --- a/lib/hex/api/user.ex +++ b/lib/hex/api/user.ex @@ -3,14 +3,19 @@ defmodule Hex.API.User do alias Hex.API.Client - def me(auth) do + def me(auth \\ []) do config = Client.config(auth) - :mix_hex_api_user.me(config) + + Hex.Auth.with_api(:read, config, &:mix_hex_api_user.me(&1)) end - def get(username) do - config = Client.config() - :mix_hex_api_user.get(config, to_string(username)) + def get(username, auth \\ []) do + config = Client.config(auth) + + Hex.Auth.with_api(:read, config, &:mix_hex_api_user.get(&1, to_string(username)), + auth_inline: false, + optional: true + ) end # NOTE: Only used for testing diff --git a/lib/hex/application.ex b/lib/hex/application.ex index 18412b06..72dd23a6 100644 --- a/lib/hex/application.ex +++ b/lib/hex/application.ex @@ -49,8 +49,6 @@ defmodule Hex.Application do defp children do [ Hex.Netrc.Cache, - Hex.OAuth, - Hex.Repo, Hex.State, Hex.Server, {Hex.Parallel, [:hex_fetcher]} @@ -60,8 +58,6 @@ defmodule Hex.Application do defp children do [ Hex.Netrc.Cache, - Hex.OAuth, - Hex.Repo, Hex.State, Hex.Server, {Hex.Parallel, [:hex_fetcher]}, diff --git a/lib/hex/auth.ex b/lib/hex/auth.ex new file mode 100644 index 00000000..1ca0daf7 --- /dev/null +++ b/lib/hex/auth.ex @@ -0,0 +1,135 @@ +defmodule Hex.Auth do + @moduledoc false + + @doc """ + Execute a function with API authentication. + + Options: + * :auth_inline - When true (default), initiates device auth for write ops + when no credentials found. When false, returns error. + """ + def with_api(permission, config, fun, opts \\ []) do + :mix_hex_cli_auth.with_api(permission, config, fun, opts) + end + + @doc """ + Execute a function with repository authentication. + """ + def with_repo(config, fun, opts \\ []) do + case :mix_hex_cli_auth.with_repo(config, fun, opts) do + {:error, {:auth_error, :oauth_exchange_failed}} -> + raise "Failed to exchange API key for OAuth token" + + other -> + other + end + end + + @doc """ + Execute a function with preemptive authentication using the provided auth data. + """ + def with_preemptive_auth(auth, config, fun, opts \\ []) do + config = put_in(config.cli_auth_callbacks.get_auth_config, fn _ -> auth end) + :mix_hex_cli_auth.with_repo(config, fun, opts) + end + + @doc false + @spec callbacks() :: :mix_hex_cli_auth.callbacks() + def callbacks do + %{ + get_auth_config: &get_auth_config/1, + get_oauth_tokens: &get_oauth_tokens/0, + persist_oauth_tokens: &persist_oauth_tokens/4, + prompt_otp: &prompt_otp/1, + get_client_id: &Hex.API.OAuth.client_id/0, + should_authenticate: &should_authenticate/1 + } + end + + defp get_auth_config(repo) do + case {Hex.Repo.fetch_repo(repo), Hex.State.get(:api_key)} do + {{:ok, config}, nil} -> config + {{:ok, config}, api_key} -> Map.put_new(config, :api_key, api_key) + {:error, nil} -> :undefined + {:error, api_key} -> %{api_key: api_key} + end + end + + defp get_oauth_tokens do + case Hex.State.get(:oauth_token) do + nil -> + :error + + %{access_token: access_token, expires_at: expires_at} = token_data -> + tokens = %{access_token: access_token, expires_at: expires_at} + + tokens = + if token_data[:refresh_token], + do: Map.put(tokens, :refresh_token, token_data[:refresh_token]), + else: tokens + + {:ok, tokens} + end + end + + defp persist_oauth_tokens(repo, access_token, refresh_token, expires_at) + + defp persist_oauth_tokens(:global, access_token, refresh_token, expires_at) do + token_data = %{ + access_token: access_token, + expires_at: expires_at + } + + token_data = + if refresh_token, + do: Map.put(token_data, :refresh_token, refresh_token), + else: token_data + + Hex.OAuth.store_token(token_data) + :ok + end + + defp persist_oauth_tokens(repo, access_token, refresh_token, expires_at) do + token_data = %{ + access_token: access_token, + expires_at: expires_at + } + + token_data = + if refresh_token, + do: Map.put(token_data, :refresh_token, refresh_token), + else: token_data + + repo_config = + Hex.Repo.get_repo(repo) + |> Map.put(:oauth_token, token_data) + + Hex.State.fetch!(:repos) + |> Map.put(repo, repo_config) + |> Hex.Config.update_repos() + + :ok + end + + defp prompt_otp(message) do + case Hex.Shell.prompt(message) do + nil -> + :cancelled + + otp -> + otp = String.trim(otp) + Hex.State.put(:api_otp, otp) + {:ok, otp} + end + end + + defp should_authenticate(reason) + + defp should_authenticate(:no_credentials) do + Hex.Shell.yes?("No authenticated user found. Do you want to authenticate now?") + end + + defp should_authenticate(:token_refresh_failed) do + Hex.Shell.yes?("Token refresh failed. Do you want to renew your authentication?") + end +end diff --git a/lib/hex/config.ex b/lib/hex/config.ex index cd399bb3..683b9e4d 100644 --- a/lib/hex/config.ex +++ b/lib/hex/config.ex @@ -6,12 +6,12 @@ defmodule Hex.Config do {:ok, binary} -> case decode_term(binary) do {:ok, term} -> - term + migrate(term) {:error, _} -> config = decode_elixir(binary) write(config) - config + migrate(config) end {:error, _} -> @@ -19,6 +19,47 @@ defmodule Hex.Config do end end + # OAuth token maps were historically persisted with string keys. We now use + # atom keys consistently (matching the :mix_hex_cli_auth border), so migrate + # any string-keyed token maps from older configs on read. + defp migrate(config) do + Enum.map(config, fn + {:"$oauth_token", token} -> + {:"$oauth_token", migrate_oauth_token(token)} + + {:"$repos", repos} when is_map(repos) -> + repos = + Map.new(repos, fn {name, repo} -> + {name, migrate_repo_oauth_token(repo)} + end) + + {:"$repos", repos} + + pair -> + pair + end) + end + + defp migrate_repo_oauth_token(repo) when is_map(repo) do + case repo do + %{oauth_token: token} -> %{repo | oauth_token: migrate_oauth_token(token)} + _ -> repo + end + end + + defp migrate_repo_oauth_token(repo), do: repo + + defp migrate_oauth_token(token) when is_map(token) do + Map.new(token, fn + {"access_token", value} -> {:access_token, value} + {"refresh_token", value} -> {:refresh_token, value} + {"expires_at", value} -> {:expires_at, value} + pair -> pair + end) + end + + defp migrate_oauth_token(token), do: token + def update(config) do read() |> Keyword.merge(config) diff --git a/lib/hex/oauth.ex b/lib/hex/oauth.ex index 8677b9fe..77742b65 100644 --- a/lib/hex/oauth.ex +++ b/lib/hex/oauth.ex @@ -1,106 +1,16 @@ defmodule Hex.OAuth do @moduledoc false - alias Hex.API.OAuth - - @refresh_cache __MODULE__.RefreshCache - @refresh_timeout 60_000 - - def start_link(_args) do - Hex.OnceCache.start_link(name: @refresh_cache) - end - - def child_spec(arg) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [arg]} - } - end - @doc """ - Retrieves a valid access token. + Gets the current access token from state. - Automatically refreshes the token if it's expired. - Returns {:error, :no_auth} if no tokens are available. - - Since we now use 2FA for write operations, we use a single token for both read and write. - - Uses Hex.OnceCache to ensure only one refresh happens per CLI invocation when multiple - concurrent requests detect an expired token. - - Options: - * :prompt_auth - if true, prompts for authentication when refresh fails (default: false) + Returns `{:ok, token}` if a valid token exists, or `{:error, reason}` otherwise. """ - def get_token(opts \\ []) do - # First, check if we have a valid token (read-only, fast path) - case get_stored_token() do - nil -> - {:error, :no_auth} - - token_data -> - if valid_token?(token_data) do - {:ok, token_data["access_token"]} - else - # Token expired, use OnceCache to ensure only one refresh/auth happens - Hex.OnceCache.fetch( - @refresh_cache, - fn -> do_refresh_or_authenticate(token_data, opts) end, - timeout: @refresh_timeout - ) - end - end - end - - defp do_refresh_or_authenticate(token_data, opts) do - case do_refresh_token(token_data) do - {:ok, new_token_data} -> - store_token(new_token_data) - {:ok, new_token_data["access_token"]} - - {:error, :refresh_failed} -> - if Keyword.get(opts, :prompt_auth, false) do - reauthenticate("Token refresh failed. Re-authenticating...") - else - warn_unusable_token() - {:error, :refresh_failed} - end - - {:error, :no_refresh_token} -> - if Keyword.get(opts, :prompt_auth, false) do - reauthenticate("Access token expired and could not be refreshed. Re-authenticating...") - else - warn_unusable_token() - {:error, :no_refresh_token} - end - end - end - - # Runs at most once per VM: the refresh result, including errors, is - # cached in the OnceCache. Without this warning an expired session - # degrades silently — requests are sent without credentials and - # private resources fail with errors that look like permission - # problems. - defp warn_unusable_token() do - Hex.Shell.warn( - "Your authentication session has expired and could not be refreshed. " <> - "Continuing without credentials, requests for private resources will fail. " <> - "Run `mix hex.user auth` to re-authenticate" - ) - end - - defp reauthenticate(message) do - Hex.Shell.info(message) - - if Hex.Shell.yes?("Do you want to authenticate now?") do - case Mix.Tasks.Hex.auth() do - {:ok, token_data} -> - {:ok, token_data["access_token"]} - - :error -> - {:error, :auth_failed} - end - else - {:error, :auth_declined} + def get_token do + case Hex.State.get(:oauth_token) do + nil -> {:error, :no_token} + %{access_token: token} -> {:ok, token} + _ -> {:error, :invalid_token} end end @@ -111,9 +21,9 @@ defmodule Hex.OAuth do Expected format: %{ - "access_token" => "...", - "refresh_token" => "...", - "expires_at" => unix_timestamp + access_token: "...", + refresh_token: "...", + expires_at: unix_timestamp } """ def store_token(token_data) do @@ -122,95 +32,17 @@ defmodule Hex.OAuth do end @doc """ - Clears all stored OAuth tokens and the refresh cache. + Clears all stored OAuth tokens. """ def clear_tokens do Hex.Config.remove([:"$oauth_token"]) Hex.State.put(:oauth_token, nil) - Hex.OnceCache.clear(@refresh_cache) end @doc """ Checks if we have any OAuth tokens stored. """ def has_tokens? do - get_stored_token() != nil - end - - @doc """ - Refreshes the stored OAuth token. - - This is primarily for manual refresh operations. Most code should use get_token/0 - which automatically refreshes when needed. - """ - def refresh_token do - case get_stored_token() do - nil -> - {:error, :no_auth} - - token_data -> - case do_refresh_token(token_data) do - {:ok, new_token_data} -> - # Update the token in state - store_token(new_token_data) - {:ok, new_token_data["access_token"]} - - error -> - error - end - end - end - - @doc """ - Creates token data with expiration time from OAuth response. - """ - def create_token_data(oauth_response) do - expires_at = System.system_time(:second) + oauth_response["expires_in"] - - oauth_response - |> Map.put("expires_at", expires_at) - |> Map.take(["access_token", "refresh_token", "expires_at"]) - end - - defp get_stored_token do - Hex.State.get(:oauth_token) - end - - defp valid_token?(token_data) do - case token_data do - %{"access_token" => token, "expires_at" => expires_at} when is_binary(token) -> - current_time = System.system_time(:second) - # Consider token expired if it expires within the next 5 minutes - expires_at > current_time + 300 - - _ -> - false - end - end - - defp do_refresh_token(token_data) do - if token_data["refresh_token"] do - case OAuth.refresh_token(token_data["refresh_token"]) do - {:ok, {200, _, new_token_data}} -> - # Update the token data with new values - expires_at = System.system_time(:second) + new_token_data["expires_in"] - - new_token_data = - new_token_data - |> Map.put("expires_at", expires_at) - |> Map.take(["access_token", "refresh_token", "expires_at"]) - - {:ok, new_token_data} - - {:ok, {status, _, _error}} when status >= 400 -> - {:error, :refresh_failed} - - {:error, _reason} -> - {:error, :refresh_failed} - end - else - # No refresh token available, return error - {:error, :no_refresh_token} - end + Hex.State.get(:oauth_token) != nil end end diff --git a/lib/hex/once_cache.ex b/lib/hex/once_cache.ex deleted file mode 100644 index d82e0f63..00000000 --- a/lib/hex/once_cache.ex +++ /dev/null @@ -1,193 +0,0 @@ -defmodule Hex.OnceCache do - @moduledoc """ - A cache that computes values at most once and caches them. - - Supports both single-value caching via `fetch/3` and keyed caching via - `fetch_key/4`. Computations run in the caller's process, allowing concurrent - computations for different keys. Multiple callers requesting the same key - will wait for the first caller's computation to complete. - - ## Example - - # Start the cache with a name - {:ok, _} = Hex.OnceCache.start_link(name: MyCache) - - # First call computes and caches - Hex.OnceCache.fetch(MyCache, fn -> - IO.puts("Computing...") - :expensive_result - end) - # => :expensive_result - - # Subsequent calls return cached value - Hex.OnceCache.fetch(MyCache, fn -> - IO.puts("Computing...") - :expensive_result - end) - # => :expensive_result (no "Computing..." output) - """ - - use GenServer - - @doc """ - Starts a new OnceCache. - - ## Options - - * `:name` - The name to register the cache under (required) - """ - def start_link(opts) do - name = Keyword.fetch!(opts, :name) - GenServer.start_link(__MODULE__, :ok, name: name) - end - - @doc """ - Fetches the cached value or computes it if not yet cached. - - The compute function is only called once, even with concurrent access. - All callers will receive the same computed value. - - ## Options - - * `:timeout` - The timeout in milliseconds for the fetch operation (default: 5000). - Use `:infinity` for operations that may take a long time (e.g., user interaction). - """ - def fetch(name, compute_fun, opts \\ []) do - fetch_key(name, :__single__, compute_fun, opts) - end - - @doc """ - Fetches a keyed cached value or computes it if not yet cached. - - Like `fetch/3`, but supports multiple independent cached values identified by key. - The compute function is only called once per key, even with concurrent access. - Computations for different keys run concurrently in their respective caller processes. - - Should not be mixed with `fetch/3` or `put/2` on the same cache. - """ - def fetch_key(name, key, compute_fun, opts \\ []) do - timeout = Keyword.get(opts, :timeout, 5000) - - case GenServer.call(name, {:fetch, key}, timeout) do - {:ok, value} -> - value - - :compute -> - try do - value = compute_fun.() - :ok = GenServer.call(name, {:computed, key, value}, timeout) - value - catch - kind, reason -> - GenServer.cast(name, {:failed, key}) - :erlang.raise(kind, reason, __STACKTRACE__) - end - end - end - - @doc """ - Stores a value in the cache without computing it. - """ - def put(name, value) do - GenServer.call(name, {:put, :__single__, value}) - end - - @doc """ - Clears the cache. - """ - def clear(name) do - GenServer.call(name, :clear) - end - - # GenServer callbacks - - @impl true - def init(:ok) do - {:ok, %{}} - end - - @impl true - def handle_call({:fetch, key}, {pid, _} = from, state) do - case Map.get(state, key) do - {:cached, value} -> - {:reply, {:ok, value}, state} - - {:computing, _mon_ref, _waiters} -> - {:noreply, update_waiters(state, key, from)} - - nil -> - mon_ref = Process.monitor(pid) - {:reply, :compute, Map.put(state, key, {:computing, mon_ref, []})} - end - end - - def handle_call({:computed, key, value}, _from, state) do - case Map.get(state, key) do - {:computing, mon_ref, waiters} -> - Process.demonitor(mon_ref, [:flush]) - - for waiter <- waiters do - GenServer.reply(waiter, {:ok, value}) - end - - {:reply, :ok, Map.put(state, key, {:cached, value})} - - _ -> - {:reply, :ok, Map.put(state, key, {:cached, value})} - end - end - - def handle_call({:put, key, value}, _from, state) do - {:reply, :ok, Map.put(state, key, {:cached, value})} - end - - def handle_call(:clear, _from, _state) do - {:reply, :ok, %{}} - end - - @impl true - def handle_cast({:failed, key}, state) do - case Map.get(state, key) do - {:computing, mon_ref, waiters} -> - Process.demonitor(mon_ref, [:flush]) - {:noreply, hand_off_or_remove(state, key, waiters)} - - _ -> - {:noreply, state} - end - end - - @impl true - def handle_info({:DOWN, mon_ref, :process, _pid, _reason}, state) do - case find_computing_key(state, mon_ref) do - {key, waiters} -> - {:noreply, hand_off_or_remove(state, key, waiters)} - - nil -> - {:noreply, state} - end - end - - defp update_waiters(state, key, from) do - Map.update!(state, key, fn {:computing, mon_ref, waiters} -> - {:computing, mon_ref, [from | waiters]} - end) - end - - defp hand_off_or_remove(state, key, [{pid, _} = next | rest]) do - new_mon_ref = Process.monitor(pid) - GenServer.reply(next, :compute) - Map.put(state, key, {:computing, new_mon_ref, rest}) - end - - defp hand_off_or_remove(state, key, []) do - Map.delete(state, key) - end - - defp find_computing_key(state, mon_ref) do - Enum.find_value(state, fn - {key, {:computing, ^mon_ref, waiters}} -> {key, waiters} - _ -> nil - end) - end -end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index d588eea6..17820074 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -58,9 +58,6 @@ defmodule Hex.RemoteConverger do |> Enum.concat() |> verify_prefetches() - # Only preflight user OAuth when one of the relevant repos would - # actually rely on the stored OAuth token. Public-only deps.get - # should not prompt just because an unrelated token expired. check_and_refresh_auth(prefetches) Registry.prefetch(prefetches) @@ -857,33 +854,41 @@ defmodule Hex.RemoteConverger do defp check_and_refresh_auth(prefetches) do if auth_preflight_required?(prefetches) do # Try to get token with authentication prompting enabled - # The OnceCache ensures only one process prompts even if multiple processes + # hex_cli_auth ensures only one process prompts even if multiple processes # detect the expired token concurrently - case Hex.OAuth.get_token(prompt_auth: true) do - {:ok, _access_token} -> + + config = Hex.API.Client.config([]) + + :read + |> Hex.Auth.with_api( + config, + fn + %{api_key: api_key} when is_binary(api_key) -> :ok + %{} -> {:error, :no_auth} + end, + optional: true, + auth_inline: true + ) + |> case do + :ok -> # Token is valid, was successfully refreshed, or user authenticated :ok - {:error, :auth_failed} -> + {:error, {:auth_error, :auth_declined}} -> + # User declined authentication Hex.Shell.warn( - "Authentication failed. Private packages will not be available. " <> + "Private packages will not be available. " <> "Run `mix hex.user auth` to authenticate." ) - {:error, :auth_declined} -> + {:error, {:auth_error, _reason}} -> Hex.Shell.warn( - "Private packages will not be available. " <> + "Authentication failed. Private packages will not be available. " <> "Run `mix hex.user auth` to authenticate." ) - {:error, :no_auth} -> - # No stored OAuth token to preflight; continue unauthenticated - # and let the repo fetch fail normally if authentication is required. - :ok - - {:error, _other} -> - # Do not fail dependency resolution during OAuth preflight; continue - # unauthenticated and let the repo fetch surface any auth error. + {:error, _reason} -> + # Other errors (shouldn't happen with prompt_auth: true, but handle gracefully) :ok end else diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index 071e8288..d81b4a17 100644 --- a/lib/hex/repo.ex +++ b/lib/hex/repo.ex @@ -1,8 +1,6 @@ defmodule Hex.Repo do @moduledoc false - @exchange_cache __MODULE__.ExchangeCache - @exchange_timeout 60_000 @hexpm_url "https://repo.hex.pm" @hexpm_public_key """ -----BEGIN PUBLIC KEY----- @@ -16,21 +14,6 @@ defmodule Hex.Repo do -----END PUBLIC KEY----- """ - def start_link(_args) do - Hex.OnceCache.start_link(name: @exchange_cache) - end - - def child_spec(arg) do - %{ - id: __MODULE__, - start: {__MODULE__, :start_link, [arg]} - } - end - - def clear_exchange_cache do - Hex.OnceCache.clear(@exchange_cache) - end - def fetch_repo(repo) do repo = repo || "hexpm" repos = Hex.State.fetch!(:repos) @@ -61,7 +44,6 @@ defmodule Hex.Repo do end defp default_organization(repo, source, name) do - url = merge_values(Map.get(repo, :url), source.url <> "/repos/#{name}") public_key = merge_values(Map.get(repo, :public_key), source.public_key) auth_key = merge_values(Map.get(repo, :auth_key), source.auth_key) @@ -69,10 +51,13 @@ defmodule Hex.Repo do merge_values(Map.get(repo, :oauth_exchange), Map.get(source, :oauth_exchange)) oauth_exchange_url = - merge_values(Map.get(repo, :oauth_exchange_url), Map.get(source, :oauth_exchange_url)) + merge_values( + Map.get(repo, :oauth_exchange_url, :undefined), + Map.get(source, :oauth_exchange_url, :undefined) + ) repo - |> Map.put(:url, url) + |> put_organization_url(source, name) |> Map.put(:public_key, public_key) |> Map.put(:auth_key, auth_key) |> Map.put(:oauth_exchange, oauth_exchange) @@ -80,6 +65,20 @@ defmodule Hex.Repo do |> Map.put(:trusted, Map.has_key?(repo, :auth_key) or source.trusted) end + # build_url appends "/repos/" via :repo_organization. Normalize a missing + # or baked-in "/repos/" URL to that form; keep a custom URL as-is. + defp put_organization_url(repo, source, name) do + url = Map.get(repo, :url) + + if url in [nil, source.url <> "/repos/#{name}"] do + repo + |> Map.put(:url, source.url) + |> Map.put(:repo_organization, name) + else + repo + end + end + def hexpm_repo() do trusted_mirror_url = Hex.State.fetch!(:trusted_mirror_url) mirror_url = Hex.State.fetch!(:mirror_url) @@ -160,13 +159,8 @@ defmodule Hex.Repo do case split_repo_name(name) do [source, organization] -> source = get_repo(source) - - repo = - repo - |> put_organization_url(organization, source) - |> clean_repo(source) - - {name, repo} + default = default_organization(%{}, source, organization) + {name, clean_repo(repo, default)} _ -> {name, Map.delete(repo, :trusted)} @@ -174,14 +168,6 @@ defmodule Hex.Repo do end) end - defp put_organization_url(repo, organization, source_repo) do - if repo.url == source_repo.url <> "/repos/#{organization}" do - Map.delete(repo, :url) - else - repo - end - end - def clean_hexpm(repos) do hexpm = hexpm_repo() repo = Map.get(repos, "hexpm", hexpm) @@ -204,7 +190,7 @@ defmodule Hex.Repo do defp clean_expired_oauth_token(repo) do case repo[:oauth_token] do - %{"expires_at" => expires_at} -> + %{expires_at: expires_at} -> current_time = System.system_time(:second) # Keep token if it's valid for more than 5 minutes (300 seconds) @@ -220,36 +206,44 @@ defmodule Hex.Repo do end defp merge_values(nil, right), do: right + defp merge_values(:undefined, right), do: right defp merge_values(left, _right), do: left def get_package(repo, package, etag) do repo_config = get_repo(repo) config = build_hex_core_config(repo_config, repo, etag) - :mix_hex_repo.get_package(config, package) + + Hex.Auth.with_repo(config, &:mix_hex_repo.get_package(&1, package), optional: true) end def get_docs(repo, package, version) do repo_config = get_repo(repo) config = build_hex_core_config(repo_config, repo) - :mix_hex_repo.get_docs(config, package, version) + + Hex.Auth.with_repo(config, &:mix_hex_repo.get_docs(&1, package, version), optional: true) end def get_tarball(repo, package, version) do repo_config = get_repo(repo) config = build_hex_core_config(repo_config, repo) - :mix_hex_repo.get_tarball(config, package, version) + + Hex.Auth.with_repo(config, &:mix_hex_repo.get_tarball(&1, package, version), optional: true) end def get_public_key(repo_config) when is_map(repo_config) do config = build_hex_core_config(repo_config, "") - :mix_hex_repo.get_public_key(config) + + Hex.Auth.with_preemptive_auth(repo_config, config, &:mix_hex_repo.get_public_key/1, + auth_inline: false, + optional: true + ) end def get_installs() do repo = get_repo("hexpm") config = build_hex_core_config(repo, "") - :mix_hex_repo.get_hex_installs(config) + Hex.Auth.with_repo(config, &:mix_hex_repo.get_hex_installs/1) end def find_new_version_from_csv(body) do @@ -303,54 +297,32 @@ defmodule Hex.Repo do unsafe_registry = Hex.State.fetch!(:unsafe_registry) no_verify_repo_origin = Hex.State.fetch!(:no_verify_repo_origin) + {repo_name, organization} = + case split_repo_name(repo_name) do + [source, organization] -> {source, organization} + [name] -> {name, nil} + end + config = %{ - :mix_hex_core.default_config() - | http_adapter: {Hex.HTTP, %{}}, - repo_name: hex_to_actual_repo_name(repo_name), + Hex.API.Client.config() + | repo_name: repo_name, + repo_organization: Map.get(repo_config, :repo_organization, :undefined), repo_url: repo_config.url, repo_public_key: Map.get(repo_config, :public_key), repo_verify: !unsafe_registry, repo_verify_origin: !no_verify_repo_origin, - http_user_agent_fragment: Hex.API.Client.user_agent_fragment() + trusted: Map.get(repo_config, :trusted, false), + oauth_exchange: Map.get(repo_config, :oauth_exchange, false) } + if repo_config.auth_key do + maybe_warn_deprecated_repo_key(repo_name, organization, repo_config) + end + config = - cond do - # First priority: explicit repo auth key with OAuth exchange disabled - use API key directly - repo_config.auth_key && Map.get(repo_config, :trusted, true) && - !Map.get(repo_config, :oauth_exchange, false) -> - %{config | repo_key: repo_config.auth_key} - - # Second priority: Exchange API key for OAuth token if enabled - repo_config.auth_key && Map.get(repo_config, :trusted, true) && - Map.get(repo_config, :oauth_exchange, false) -> - maybe_warn_deprecated_repo_key(repo_name, repo_config) - - case exchange_api_key_for_token(repo_config, repo_name) do - {:ok, access_token} -> - %{config | repo_key: "Bearer #{access_token}"} - - {:error, reason} -> - raise "Failed to exchange API key for OAuth token: #{inspect(reason)}" - end - - # Third priority: fallback to OAuth token if available, but only for - # trusted repos. Untrusted mirrors/custom repos must not receive the - # user bearer token. - Map.get(repo_config, :trusted, true) -> - case Hex.OAuth.get_token() do - {:ok, access_token} -> - # Format as Bearer token for OAuth authentication - %{config | repo_key: "Bearer #{access_token}"} - - {:error, _reason} -> - # No authentication available - continue without auth - # Server will return 401/403 if authentication is required - config - end - - true -> - config + case Map.fetch(repo_config, :oauth_exchange_url) do + {:ok, oauth_exchange_url} -> Map.put(config, :oauth_exchange_url, oauth_exchange_url) + :error -> Map.put(config, :oauth_exchange_url, config.api_url) end if etag do @@ -360,76 +332,35 @@ defmodule Hex.Repo do end end - defp hex_to_actual_repo_name("hexpm:" <> repo), do: repo - defp hex_to_actual_repo_name(repo), do: repo - - defp exchange_api_key_for_token(repo_config, repo_name) do - case get_cached_token(repo_config) do - {:ok, access_token} -> - {:ok, access_token} - - _expired_or_not_found -> - Hex.OnceCache.fetch_key( - @exchange_cache, - {repo_name, repo_config.auth_key}, - fn -> do_exchange_api_key(repo_config, repo_name) end, - timeout: @exchange_timeout - ) - end - end - - defp get_cached_token(repo_config) do - case Map.get(repo_config, :oauth_token) do - %{"access_token" => token, "expires_at" => expires_at} -> - current_time = System.system_time(:second) + defp maybe_warn_deprecated_repo_key("hexpm", organization, repo_config) do + case deprecated_repo_key_source(repo_config) do + :env -> + if Hex.Server.should_warn?(:deprecated_repos_key) do + Hex.Shell.warn(""" + HEX_REPOS_KEY is deprecated and will be removed. - if expires_at > current_time + 300 do - {:ok, token} - else - :expired + For development authenticate as a user with `mix hex.user auth`. For CI \ + authenticate per organization with `mix hex.organization auth ORGANIZATION --key KEY`. + """) end - _ -> - :not_found - end - end + :config -> + organization = organization || "hexpm" - defp maybe_warn_deprecated_repo_key(repo_name, repo_config) do - if hexpm_repo_name?(repo_name) do - case deprecated_repo_key_source(repo_config) do - :env -> - if Hex.Server.should_warn?(:deprecated_repos_key) do - Hex.Shell.warn(""" - HEX_REPOS_KEY is deprecated and will be removed. - - For development authenticate as a user with `mix hex.user auth`. For CI \ - authenticate per organization with `mix hex.organization auth ORGANIZATION --key KEY`. - """) - end - - :config -> - organization = repo_organization(repo_name) - - if Hex.Server.should_warn?({:deprecated_repo_key, organization}) do - Hex.Shell.warn(""" - Authenticating to the #{organization} repository with a stored key is deprecated \ - and will be removed. - - For development authenticate as a user with `mix hex.user auth`. For CI generate an \ - organization key with `mix hex.organization key #{organization} generate` and pass it \ - with `mix hex.organization auth #{organization} --key KEY`. - """) - end - end + if Hex.Server.should_warn?({:deprecated_repo_key, organization}) do + Hex.Shell.warn(""" + Authenticating to the #{organization} repository with a stored key is deprecated \ + and will be removed. + + For development authenticate as a user with `mix hex.user auth`. For CI generate an \ + organization key with `mix hex.organization key #{organization} generate` and pass it \ + with `mix hex.organization auth #{organization} --key KEY`. + """) + end end end - defp hexpm_repo_name?("hexpm"), do: true - defp hexpm_repo_name?("hexpm:" <> _), do: true - defp hexpm_repo_name?(_), do: false - - defp repo_organization("hexpm:" <> organization), do: organization - defp repo_organization("hexpm"), do: "hexpm" + defp maybe_warn_deprecated_repo_key(_repo_name, _organization, _repo_config), do: :ok # HEX_REPOS_KEY is exposed as the hexpm source `auth_key` and inherited by # `hexpm:*` repos, so an `auth_key` equal to `repos_key` came from the @@ -443,48 +374,4 @@ defmodule Hex.Repo do :config end end - - defp do_exchange_api_key(repo_config, repo_name) do - api_key = repo_config.auth_key - scopes = "repositories" - oauth_url = Map.get(repo_config, :oauth_exchange_url) - name = get_hostname() - - case Hex.API.OAuth.exchange_api_key(api_key, scopes, name, oauth_url) do - {:ok, {200, _, response}} when is_map(response) -> - access_token = response["access_token"] - expires_in = response["expires_in"] || 1800 - - cache_token(repo_config, repo_name, access_token, expires_in) - - {:ok, access_token} - - {:ok, {status, _, _}} when status >= 400 -> - {:error, :exchange_failed} - - {:error, reason} -> - {:error, reason} - end - end - - defp get_hostname do - case :inet.gethostname() do - {:ok, hostname} -> to_string(hostname) - {:error, _} -> nil - end - end - - defp cache_token(repo_config, repo_name, access_token, expires_in) do - expires_at = System.system_time(:second) + expires_in - - token_data = %{ - "access_token" => access_token, - "expires_at" => expires_at - } - - repos = Hex.State.fetch!(:repos) - updated_repo = Map.put(repo_config, :oauth_token, token_data) - updated_repos = Map.put(repos, repo_name, updated_repo) - Hex.Config.update_repos(updated_repos) - end end diff --git a/lib/hex/stdlib.ex b/lib/hex/stdlib.ex index b1ff1c5e..1190e4e3 100644 --- a/lib/hex/stdlib.ex +++ b/lib/hex/stdlib.ex @@ -1,22 +1,6 @@ defmodule Hex.Stdlib do @moduledoc false - # TODO: Remove this once we require OTP 24.0 - def ssh_hostkey_fingerprint(digset_type, key) do - cond do - # Requires Elixir 1.15.0 - function_exported?(Mix, :ensure_application!, 1) -> - apply(Mix, :ensure_application!, [:ssh]) - apply(:ssh, :hostkey_fingerprint, [digset_type, key]) - - Code.ensure_loaded?(:ssh) and function_exported?(:ssh, :hostkey_fingerprint, 2) -> - apply(:ssh, :hostkey_fingerprint, [digset_type, key]) - - true -> - apply(:public_key, :ssh_hostkey_fingerprint, [digset_type, key]) - end - end - # Compilation prunes code paths for isolation, which may remove archive # paths like Hex. Restore them so all Hex modules are available. def ensure_application!(app) do diff --git a/lib/mix/tasks/hex.docs.ex b/lib/mix/tasks/hex.docs.ex index e2ed9059..c07f05b0 100644 --- a/lib/mix/tasks/hex.docs.ex +++ b/lib/mix/tasks/hex.docs.ex @@ -156,9 +156,7 @@ defmodule Mix.Tasks.Hex.Docs do end defp retrieve_package_info(organization, name) do - auth = if organization, do: Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.Package.get(organization, name, auth) do + case Hex.API.Package.get(organization, name) do {:ok, {code, _, body}} when code in 200..299 -> body diff --git a/lib/mix/tasks/hex.ex b/lib/mix/tasks/hex.ex index 3d1a26e3..20864b48 100644 --- a/lib/mix/tasks/hex.ex +++ b/lib/mix/tasks/hex.ex @@ -125,98 +125,43 @@ defmodule Mix.Tasks.Hex do auth_device(opts) end - defp get_hostname() do - case :inet.gethostname() do - {:ok, hostname} -> to_string(hostname) - {:error, _} -> nil - end - end - @doc false def auth_device(_opts \\ []) do # Clean up any existing authentication revoke_existing_oauth_tokens() revoke_and_cleanup_old_api_keys() - name = get_hostname() - - case Hex.API.OAuth.device_authorization("api repositories", name) do - {:ok, {200, _, device_response}} -> - perform_device_flow(device_response) - - {:ok, {status, _, error}} -> - Hex.Shell.error("Device authorization failed (#{status}): #{inspect(error)}") - :error - - {:error, reason} -> - Hex.Shell.error("Device authorization error: #{inspect(reason)}") - :error - end - end - - defp perform_device_flow(device_response) do - device_code = device_response["device_code"] - user_code = device_response["user_code"] - verification_uri = device_response["verification_uri"] - verification_uri_complete = device_response["verification_uri_complete"] - interval = device_response["interval"] || 5 - - # Use the complete URI if available (has user code pre-filled), otherwise fall back to basic URI - uri_to_open = verification_uri_complete || verification_uri - - Hex.Shell.info("To authenticate, visit: #{uri_to_open}") - Hex.Shell.info("") - Hex.Shell.info("Your verification code:") - Hex.Shell.info("") - Hex.Shell.info(" #{format_user_code(user_code)}") - Hex.Shell.info("") - Hex.Shell.info("Verify this code matches what is shown in your browser.") - Hex.Shell.info("") - - # Automatically open the browser - Hex.Utils.system_open(uri_to_open) - - Hex.Shell.info("Waiting for authentication...") - - case poll_for_token(device_code, interval) do - {:ok, token} -> - store_token(token) - - :error -> - :error + prompt_user = fn verification_uri, user_code -> + Hex.Shell.info("To authenticate, visit: #{verification_uri}") + Hex.Shell.info("") + Hex.Shell.info("Your verification code:") + Hex.Shell.info("") + Hex.Shell.info(" #{format_user_code(user_code)}") + Hex.Shell.info("") + Hex.Shell.info("Verify this code matches what is shown in your browser.") + Hex.Shell.info("") + Hex.Shell.info("Waiting for authentication...") + :ok end - end - defp poll_for_token(device_code, interval, attempt \\ 1) do - case Hex.API.OAuth.poll_device_token(device_code) do - {:ok, {200, _, token_response}} -> - {:ok, token_response} + case Hex.API.OAuth.device_auth_flow("api repositories", prompt_user, open_browser: true) do + {:ok, tokens} -> + store_token(tokens) - {:ok, {400, _, %{"error" => "authorization_pending"}}} -> - if attempt > 120 do - Hex.Shell.error("Authentication timed out. Please try again.") - :error - else - Process.sleep(interval * 1000) - poll_for_token(device_code, interval, attempt + 1) - end - - {:ok, {400, _, %{"error" => "slow_down"}}} -> - # Increase polling interval - new_interval = min(interval * 2, 30) - Process.sleep(new_interval * 1000) - poll_for_token(device_code, new_interval, attempt + 1) - - {:ok, {400, _, %{"error" => "expired_token"}}} -> + {:error, :timeout} -> Hex.Shell.error("Device code expired. Please try again.") :error - {:ok, {403, _, %{"error" => "access_denied"}}} -> + {:error, {:access_denied, _status, _body}} -> Hex.Shell.error("Authentication was denied.") :error - {:ok, {status, _, error}} -> - Hex.Shell.error("Authentication failed (#{status}): #{inspect(error)}") + {:error, {:device_auth_failed, status, body}} -> + Hex.Shell.error("Device authorization failed (#{status}): #{inspect(body)}") + :error + + {:error, {:poll_failed, status, body}} -> + Hex.Shell.error("Authentication failed (#{status}): #{inspect(body)}") :error {:error, reason} -> @@ -232,25 +177,22 @@ defmodule Mix.Tasks.Hex do "-" <> String.slice(user_code, mid, String.length(user_code)) end - defp store_token(token) do - # Create token data with expiration time - token_data = Hex.OAuth.create_token_data(token) - - # Store a single token for both read and write operations - # With 2FA now required for write operations, we don't need separate tokens - Hex.OAuth.store_token(token_data) + defp store_token(tokens) do + Hex.OAuth.store_token(tokens) Hex.Shell.info("You are authenticated!") - {:ok, token_data} + {:ok, tokens} end @doc false - def generate_organization_key(organization_name, key_name, permissions, auth \\ nil) do - auth = auth || auth_info(:write) - - case Hex.API.Key.Organization.new(organization_name, key_name, permissions, auth) do + def generate_organization_key(organization_name, key_name, permissions) do + case Hex.API.Key.Organization.new(organization_name, key_name, permissions) do {:ok, {201, _, body}} -> {:ok, body["secret"]} + {:error, {:auth_error, _}} -> + Mix.shell().error("Generation of key failed: authentication required") + :error + other -> Mix.shell().error("Generation of key failed") Hex.Utils.print_error_result(other) @@ -294,7 +236,7 @@ defmodule Mix.Tasks.Hex do :ok token_data when is_map(token_data) -> - if access_token = token_data["access_token"] do + if access_token = token_data[:access_token] do case Hex.API.OAuth.revoke_token(access_token) do {:ok, {code, _, _}} when code in 200..299 -> :ok @@ -344,167 +286,6 @@ defmodule Mix.Tasks.Hex do |> Hex.Config.update_repos() end - defp prompt_otp() do - Hex.Shell.info("") - - Hex.Shell.prompt("Enter your 2FA code:") - |> String.trim() - end - - @doc """ - Returns authentication info for the given operation type. - - The permission parameter determines whether to include OTP for 2FA: - - :write - includes OTP if available (required for write operations with 2FA) - - :read - does not include OTP - - Both read and write operations use the same OAuth token. - """ - def auth_info(permission, opts \\ []) - - def auth_info(:write, opts) do - # Try OAuth tokens first - case Hex.OAuth.get_token() do - {:ok, access_token} -> - # Don't prompt for OTP upfront - will be prompted if server requires it - otp = Hex.State.fetch!(:api_otp) - - if otp do - [key: access_token, oauth: true, otp: otp] - else - [key: access_token, oauth: true] - end - - {:error, :refresh_failed} -> - Hex.Shell.info("Token refresh failed. Please re-authenticate.") - - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - {:error, :no_refresh_token} -> - Hex.Shell.info("Access token expired and could not be refreshed. Please re-authenticate.") - - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - {:error, :no_auth} -> - # Fall back to API key from config/env - case Hex.State.fetch!(:api_key) do - nil -> - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - api_key -> - [key: api_key] - end - end - end - - def auth_info(:read, opts) do - # Try OAuth tokens first - case Hex.OAuth.get_token() do - {:ok, access_token} -> - [key: access_token, oauth: true] - - {:error, :refresh_failed} -> - Hex.Shell.info("Token refresh failed. Please re-authenticate.") - - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - {:error, :no_refresh_token} -> - Hex.Shell.info("Access token expired and could not be refreshed. Please re-authenticate.") - - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - {:error, :no_auth} -> - # Fall back to API key from config/env (write key can be used for read) - case Hex.State.fetch!(:api_key) do - nil -> - if Keyword.get(opts, :auth_inline, true) do - authenticate_inline() - else - [] - end - - api_key -> - [key: api_key] - end - end - end - - defp authenticate_inline() do - authenticate? = - Hex.Shell.yes?("No authenticated user found. Do you want to authenticate now?") - - if authenticate? do - case auth() do - {:ok, _tokens} -> - # Auth succeeded, try to get token - case Hex.OAuth.get_token() do - {:ok, access_token} -> - # Don't prompt for OTP upfront - will be prompted if server requires it - otp = Hex.State.fetch!(:api_otp) - - if otp do - [key: access_token, oauth: true, otp: otp] - else - [key: access_token, oauth: true] - end - - {:error, _} -> - no_auth_error() - end - - :error -> - no_auth_error() - end - else - no_auth_error() - end - end - - defp no_auth_error() do - Mix.raise("No authenticated user found. Run `mix hex.user auth`") - end - - @doc false - def with_otp_retry(auth, fun) when is_function(fun, 1) do - case fun.(auth) do - {:error, :otp_required} -> - otp = prompt_otp() - Hex.State.put(:api_otp, otp) - auth_with_otp = Keyword.put(auth, :otp, otp) - with_otp_retry(auth_with_otp, fun) - - {:error, :invalid_totp} -> - Hex.Shell.error("Invalid two-factor authentication code") - otp = prompt_otp() - Hex.State.put(:api_otp, otp) - auth_with_otp = Keyword.put(auth, :otp, otp) - with_otp_retry(auth_with_otp, fun) - - result -> - result - end - end - @doc false def required_opts(opts, required) do Enum.map(required, fn req -> diff --git a/lib/mix/tasks/hex.info.ex b/lib/mix/tasks/hex.info.ex index a71952f1..6e4676d0 100644 --- a/lib/mix/tasks/hex.info.ex +++ b/lib/mix/tasks/hex.info.ex @@ -74,9 +74,7 @@ defmodule Mix.Tasks.Hex.Info do end defp package(organization, package) do - auth = organization && Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.Package.get(organization, package, auth) do + case Hex.API.Package.get(organization, package) do {:ok, {code, _, body}} when code in 200..299 -> print_package(body, locked_dep(package)) @@ -92,9 +90,7 @@ defmodule Mix.Tasks.Hex.Info do end defp release(organization, package, version) do - auth = organization && Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.Release.get(organization, package, version, auth) do + case Hex.API.Release.get(organization, package, version) do {:ok, {code, _, body}} when code in 200..299 -> print_release(organization, package, body) diff --git a/lib/mix/tasks/hex.organization.ex b/lib/mix/tasks/hex.organization.ex index 980319a1..28258134 100644 --- a/lib/mix/tasks/hex.organization.ex +++ b/lib/mix/tasks/hex.organization.ex @@ -188,9 +188,8 @@ defmodule Mix.Tasks.Hex.Organization do key_name = Mix.Tasks.Hex.repository_key_name(organization, opts[:key_name]) permissions = [%{"domain" => "repository", "resource" => organization}] - auth = Mix.Tasks.Hex.auth_info(:write) - case Hex.API.Key.new(key_name, permissions, auth) do + case Hex.API.Key.new(key_name, permissions) do {:ok, {201, _, body}} -> body["secret"] @@ -213,11 +212,9 @@ defmodule Mix.Tasks.Hex.Organization do end defp key_revoke_all(organization) do - auth = Mix.Tasks.Hex.auth_info(:write) - Hex.Shell.info("Revoking all keys...") - case Hex.API.Key.Organization.delete_all(organization, auth) do + case Hex.API.Key.Organization.delete_all(organization) do {:ok, {code, _headers, _body}} when code in 200..299 -> :ok @@ -228,11 +225,9 @@ defmodule Mix.Tasks.Hex.Organization do end defp key_revoke(organization, key) do - auth = Mix.Tasks.Hex.auth_info(:write) - Hex.Shell.info("Revoking key #{key}...") - case Hex.API.Key.Organization.delete(organization, key, auth) do + case Hex.API.Key.Organization.delete(organization, key) do {:ok, {code, _headers, _body}} when code in 200..299 -> :ok @@ -244,9 +239,7 @@ defmodule Mix.Tasks.Hex.Organization do # TODO: print permissions defp key_list(organization) do - auth = Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.Key.Organization.get(organization, auth) do + case Hex.API.Key.Organization.get(organization) do {:ok, {code, _headers, body}} when code in 200..299 -> values = Enum.map(body, fn %{"name" => name, "inserted_at" => time} -> diff --git a/lib/mix/tasks/hex.owner.ex b/lib/mix/tasks/hex.owner.ex index 51c61e05..9544c173 100644 --- a/lib/mix/tasks/hex.owner.ex +++ b/lib/mix/tasks/hex.owner.ex @@ -105,10 +105,9 @@ defmodule Mix.Tasks.Hex.Owner do end defp add_owner(organization, package, owner, level) when level in ~w[full maintainer] do - auth = Mix.Tasks.Hex.auth_info(:write) Hex.Shell.info("Adding owner #{owner} with ownership level #{level} to #{package}") - case Hex.API.Package.Owner.add(organization, package, owner, level, false, auth) do + case Hex.API.Package.Owner.add(organization, package, owner, level, false) do {:ok, {code, _headers, _body}} when code in 200..299 -> :ok @@ -123,10 +122,9 @@ defmodule Mix.Tasks.Hex.Owner do end defp transfer_owner(organization, package, owner) do - auth = Mix.Tasks.Hex.auth_info(:write) Hex.Shell.info("Transferring ownership to #{owner} for #{package}") - case Hex.API.Package.Owner.add(organization, package, owner, "full", true, auth) do + case Hex.API.Package.Owner.add(organization, package, owner, "full", true) do {:ok, {code, _headers, _body}} when code in 200..299 -> :ok @@ -137,10 +135,9 @@ defmodule Mix.Tasks.Hex.Owner do end defp remove_owner(organization, package, owner) do - auth = Mix.Tasks.Hex.auth_info(:write) Hex.Shell.info("Removing owner #{owner} from #{package}") - case Hex.API.Package.Owner.delete(organization, package, owner, auth) do + case Hex.API.Package.Owner.delete(organization, package, owner) do {:ok, {code, _headers, _body}} when code in 200..299 -> :ok @@ -151,9 +148,7 @@ defmodule Mix.Tasks.Hex.Owner do end defp list_owners(organization, package) do - auth = Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.Package.Owner.get(organization, package, auth) do + case Hex.API.Package.Owner.get(organization, package) do {:ok, {code, _headers, body}} when code in 200..299 -> header = ["Email", "Level"] owners = Enum.map(body, &[&1["email"], &1["level"]]) @@ -166,9 +161,7 @@ defmodule Mix.Tasks.Hex.Owner do end defp list_owned_packages() do - auth = Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.User.me(auth) do + case Hex.API.User.me() do {:ok, {code, _headers, body}} when code in 200..299 -> Enum.each(body["packages"], fn package -> name = package_name(package["repository"], package["name"]) diff --git a/lib/mix/tasks/hex.package.ex b/lib/mix/tasks/hex.package.ex index e3588fe7..883528bd 100644 --- a/lib/mix/tasks/hex.package.ex +++ b/lib/mix/tasks/hex.package.ex @@ -364,11 +364,10 @@ defmodule Mix.Tasks.Hex.Package do defp package_search(package, opts) do Hex.start() - auth = Mix.Tasks.Hex.auth_info(:read, auth_inline: false) opts |> search_repo() - |> Hex.API.Package.search(package, auth) + |> Hex.API.Package.search(package) |> lookup_packages() end diff --git a/lib/mix/tasks/hex.publish.ex b/lib/mix/tasks/hex.publish.ex index 47d8254d..faac0711 100644 --- a/lib/mix/tasks/hex.publish.ex +++ b/lib/mix/tasks/hex.publish.ex @@ -82,25 +82,21 @@ defmodule Mix.Tasks.Hex.Publish do case args do ["package"] when revert -> - auth = Mix.Tasks.Hex.auth_info(:write) - revert_package(build, organization, revert_version, auth) + revert_package(build, organization, revert_version) ["docs"] when revert -> - auth = Mix.Tasks.Hex.auth_info(:write) - revert_docs(build, organization, revert_version, auth) + revert_docs(build, organization, revert_version) [] when revert -> - auth = Mix.Tasks.Hex.auth_info(:write) - revert_package(build, organization, revert_version, auth) + revert_package(build, organization, revert_version) ["package"] -> case proceed_with_owner(build, organization, opts) do {:ok, owner} -> - auth = Mix.Tasks.Hex.auth_info(:write) Hex.Shell.info("Publishing package...") - case create_release(build, organization, auth, opts) do - :ok -> transfer_owner(build, owner, auth, opts) + case create_release(build, organization, opts) do + :ok -> transfer_owner(build, owner, opts) _ -> Mix.Tasks.Hex.set_exit_code(1) end @@ -110,8 +106,7 @@ defmodule Mix.Tasks.Hex.Publish do ["docs"] -> docs_task() - auth = Mix.Tasks.Hex.auth_info(:write) - create_docs(build, organization, auth, opts) + create_docs(build, organization, opts) [] -> create(build, organization, opts) @@ -144,16 +139,13 @@ defmodule Mix.Tasks.Hex.Publish do {:ok, owner} -> Hex.Shell.info("Building docs...") docs_task() - auth = Mix.Tasks.Hex.auth_info(:write) Hex.Shell.info("Publishing package...") - case create_release(build, organization, auth, opts) do + case create_release(build, organization, opts) do :ok -> Hex.Shell.info("Publishing docs...") - # Refresh auth to pick up cached OTP from package publish - auth = Mix.Tasks.Hex.auth_info(:write) - create_docs(build, organization, auth, opts) - transfer_owner(build, owner, auth, opts) + create_docs(build, organization, opts) + transfer_owner(build, owner, opts) _ -> Mix.Tasks.Hex.set_exit_code(1) @@ -164,7 +156,7 @@ defmodule Mix.Tasks.Hex.Publish do end end - defp create_docs(build, organization, auth, opts) do + defp create_docs(build, organization, opts) do directory = docs_dir() name = build.meta.name version = build.meta.version @@ -180,7 +172,7 @@ defmodule Mix.Tasks.Hex.Publish do if dry_run? do :ok else - send_tarball(organization, name, version, tarball, auth, progress?) + send_tarball(organization, name, version, tarball, progress?) end end @@ -254,8 +246,7 @@ defmodule Mix.Tasks.Hex.Publish do end defp print_owner_prompt(build, organization, opts) do - auth = Mix.Tasks.Hex.auth_info(:read) - organizations = user_organizations(auth) + organizations = user_organizations() owner_prompt? = public_organization?(organization) and @@ -325,11 +316,14 @@ defmodule Mix.Tasks.Hex.Publish do end end - defp user_organizations(auth) do - case Hex.API.User.me(auth) do + defp user_organizations do + case Hex.API.User.me() do {:ok, {200, _header, body}} -> Enum.map(body["organizations"], & &1["name"]) + {:error, {:auth_error, :auth_declined}} -> + Mix.raise("No authenticated user found. Run `mix hex.user auth`") + other -> Hex.Utils.print_error_result(other) [] @@ -338,18 +332,18 @@ defmodule Mix.Tasks.Hex.Publish do defp public_organization?(organization), do: organization in [nil, "hexpm"] - defp transfer_owner(_build, nil, _auth, _opts) do + defp transfer_owner(_build, nil, _opts) do :ok end - defp transfer_owner(build, owner, auth, opts) do + defp transfer_owner(build, owner, opts) do Hex.Shell.info("Transferring ownership to #{owner}...") dry_run? = Keyword.get(opts, :dry_run, false) if dry_run? do :ok else - case Hex.API.Package.Owner.add("hexpm", build.meta.name, owner, "full", true, auth) do + case Hex.API.Package.Owner.add("hexpm", build.meta.name, owner, "full", true) do {:ok, {status, _header, _body}} when status in 200..299 -> :ok @@ -367,10 +361,10 @@ defmodule Mix.Tasks.Hex.Publish do ) end - defp revert_package(build, organization, version, auth) do + defp revert_package(build, organization, version) do name = build.meta.name - case Hex.API.Release.delete(organization, name, version, auth) do + case Hex.API.Release.delete(organization, name, version) do {:ok, {code, _, _}} when code in 200..299 -> Hex.Shell.info("Reverted #{name} #{version}") @@ -380,10 +374,10 @@ defmodule Mix.Tasks.Hex.Publish do end end - defp revert_docs(build, organization, version, auth) do + defp revert_docs(build, organization, version) do name = build.meta.name - case Hex.API.ReleaseDocs.delete(organization, name, version, auth) do + case Hex.API.ReleaseDocs.delete(organization, name, version) do {:ok, {code, _, _}} when code in 200..299 -> Hex.Shell.info("Reverted docs for #{name} #{version}") @@ -422,10 +416,10 @@ defmodule Mix.Tasks.Hex.Publish do end end - defp send_tarball(organization, name, version, tarball, auth, progress?) do + defp send_tarball(organization, name, version, tarball, progress?) do progress = progress_fun(progress?, byte_size(tarball)) - case Hex.API.ReleaseDocs.publish(organization, name, version, tarball, auth, progress) do + case Hex.API.ReleaseDocs.publish(organization, name, version, tarball, [], progress) do {:ok, {code, headers, _body}} when code in 200..299 -> api_url = Hex.State.fetch!(:api_url) default_api_url? = api_url == Hex.State.default_api_url() @@ -495,7 +489,7 @@ defmodule Mix.Tasks.Hex.Publish do end end - defp create_release(build, organization, auth, opts) do + defp create_release(build, organization, opts) do meta = build.meta %{tarball: tarball, outer_checksum: checksum} = Hex.Tar.create!(meta, meta.files, :memory) dry_run? = Keyword.get(opts, :dry_run, false) @@ -504,17 +498,17 @@ defmodule Mix.Tasks.Hex.Publish do if dry_run? do :ok else - send_release(tarball, checksum, organization, auth, opts) + send_release(tarball, checksum, organization, opts) end end - defp send_release(tarball, checksum, organization, auth, opts) do + defp send_release(tarball, checksum, organization, opts) do progress? = Keyword.get(opts, :progress, true) progress = progress_fun(progress?, byte_size(tarball)) replace? = Keyword.get(opts, :replace, false) - case Hex.API.Release.publish(organization, tarball, auth, progress, replace?) do + case Hex.API.Release.publish(organization, tarball, [], progress, replace?) do {:ok, {code, _, body}} when code in 200..299 -> location = body["html_url"] || body["url"] checksum = String.downcase(Base.encode16(checksum, case: :lower)) @@ -527,7 +521,7 @@ defmodule Mix.Tasks.Hex.Publish do Hex.Shell.error("Publishing failed") package = Keyword.fetch!(opts, :name) - case Hex.API.Package.get(organization, package, auth) do + case Hex.API.Package.get(organization, package) do {:ok, {code, _, _}} when code in 200..299 -> Hex.Shell.error(""" Package with name #{Keyword.fetch!(opts, :name)} already exists. \ diff --git a/lib/mix/tasks/hex.repo.ex b/lib/mix/tasks/hex.repo.ex index 743d4045..bb926e14 100644 --- a/lib/mix/tasks/hex.repo.ex +++ b/lib/mix/tasks/hex.repo.ex @@ -246,10 +246,10 @@ defmodule Mix.Tasks.Hex.Repo do defp show_public_key(nil), do: nil defp show_public_key(public_key) do - [pem_entry] = :public_key.pem_decode(public_key) - public_key = :public_key.pem_entry_decode(pem_entry) + Hex.Stdlib.ensure_application!(:ssh) - Hex.Stdlib.ssh_hostkey_fingerprint(:sha256, public_key) + public_key + |> :mix_hex_repo.fingerprint() |> List.to_string() end @@ -265,7 +265,9 @@ defmodule Mix.Tasks.Hex.Repo do case Hex.Repo.get_public_key(repo_config) do {:ok, {200, _, key}} -> - if show_public_key(key) == fingerprint do + Hex.Stdlib.ensure_application!(:ssh) + + if :mix_hex_repo.fingerprint_equal(key, fingerprint) do key else Mix.raise("Public key fingerprint mismatch") diff --git a/lib/mix/tasks/hex.retire.ex b/lib/mix/tasks/hex.retire.ex index 4731830e..79048579 100644 --- a/lib/mix/tasks/hex.retire.ex +++ b/lib/mix/tasks/hex.retire.ex @@ -73,10 +73,9 @@ defmodule Mix.Tasks.Hex.Retire do end defp retire(organization, package, version, reason, opts) do - auth = Mix.Tasks.Hex.auth_info(:write) body = %{reason: reason, message: message_option(opts[:message])} - case Hex.API.Release.retire(organization, package, version, body, auth) do + case Hex.API.Release.retire(organization, package, version, body) do {:ok, {code, _headers, _body}} when code in 200..299 -> Hex.Shell.info("#{package} #{version} has been retired\n") @@ -87,9 +86,7 @@ defmodule Mix.Tasks.Hex.Retire do end defp unretire(organization, package, version) do - auth = Mix.Tasks.Hex.auth_info(:write) - - case Hex.API.Release.unretire(organization, package, version, auth) do + case Hex.API.Release.unretire(organization, package, version) do {:ok, {code, _headers, _body}} when code in 200..299 -> Hex.Shell.info("#{package} #{version} has been unretired") :ok diff --git a/lib/mix/tasks/hex.user.ex b/lib/mix/tasks/hex.user.ex index 7329d788..a486f006 100644 --- a/lib/mix/tasks/hex.user.ex +++ b/lib/mix/tasks/hex.user.ex @@ -66,9 +66,7 @@ defmodule Mix.Tasks.Hex.User do end defp whoami() do - auth = Mix.Tasks.Hex.auth_info(:read) - - case Hex.API.User.me(auth) do + case Hex.API.User.me() do {:ok, {code, _, body}} when code in 200..299 -> Hex.Shell.info(body["username"]) diff --git a/scripts/vendor_hex_core.sh b/scripts/vendor_hex_core.sh index 43fddf8c..9049ba2e 100755 --- a/scripts/vendor_hex_core.sh +++ b/scripts/vendor_hex_core.sh @@ -24,6 +24,7 @@ filenames="hex_api_auth.erl \ hex_api_user.erl \ hex_api.erl \ hex_advisory.erl \ + hex_cli_auth.erl \ hex_core.hrl \ hex_core.erl \ hex_erl_tar.erl \ @@ -58,6 +59,7 @@ search_to_replace="hex_core: \ hex_repo \ hex_api \ hex_advisory \ + hex_cli_auth \ safe_erl_term" rm -f $target_dir/$prefix* diff --git a/src/mix_hex_advisory.erl b/src/mix_hex_advisory.erl index 136b134d..e0ac7b83 100644 --- a/src/mix_hex_advisory.erl +++ b/src/mix_hex_advisory.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 88a2360f..e43ab603 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% Hex HTTP API @@ -19,7 +19,8 @@ -export_type([response/0]). -type response() :: {ok, {mix_hex_http:status(), mix_hex_http:headers(), body() | nil}} | {error, term()}. --type body() :: [body()] | #{binary() => body() | binary()}. +-type body() :: #{binary() => value()} | [#{binary() => value()}]. +-type value() :: binary() | boolean() | nil | number() | [value()] | #{binary() => value()}. %% @private get(Config, Path) -> diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index 7488b553..8b79ebeb 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 4304f95e..9cd5b3a3 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 b8ca65ed..beaaa1d5 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% Hex HTTP API - OAuth. @@ -6,6 +6,8 @@ -export([ device_authorization/3, device_authorization/4, + device_auth_flow/4, + device_auth_flow/5, poll_device_token/3, refresh_token/3, revoke_token/3, @@ -13,6 +15,21 @@ client_credentials_token/5 ]). +-export_type([oauth_tokens/0, device_auth_error/0]). + +-type oauth_tokens() :: #{ + access_token := binary(), + refresh_token => binary() | undefined, + expires_at := integer() +}. + +-type device_auth_error() :: + timeout + | {access_denied, Status :: non_neg_integer(), Body :: term()} + | {device_auth_failed, Status :: non_neg_integer(), Body :: term()} + | {poll_failed, Status :: non_neg_integer(), Body :: term()} + | term(). + %% @doc %% Initiates the OAuth device authorization flow. %% @@ -28,7 +45,7 @@ device_authorization(Config, ClientId, Scope) -> %% Returns device code, user code, and verification URIs for user authentication. %% %% Options: -%% * `name' - A name to identify the token (e.g., hostname of the device) +%% * `name' - A name to identify the token (defaults to the machine's hostname) %% %% Examples: %% @@ -51,17 +68,141 @@ device_authorization(Config, ClientId, Scope) -> mix_hex_api:response(). device_authorization(Config, ClientId, Scope, Opts) -> Path = <<"oauth/device_authorization">>, - Params0 = #{ - <<"client_id">> => ClientId, - <<"scope">> => Scope - }, - Params = + Name = case proplists:get_value(name, Opts) of - undefined -> Params0; - Name -> Params0#{<<"name">> => Name} + undefined -> get_hostname(); + N -> N end, + Params = #{ + <<"client_id">> => ClientId, + <<"scope">> => Scope, + <<"name">> => Name + }, mix_hex_api:post(Config, Path, Params). +%% @doc +%% Runs the complete OAuth device authorization flow. +%% +%% @see device_auth_flow/5 +%% @end +-spec device_auth_flow( + mix_hex_core:config(), + ClientId :: binary(), + Scope :: binary(), + PromptUser :: fun((VerificationUri :: binary(), UserCode :: binary()) -> ok) +) -> {ok, oauth_tokens()} | {error, device_auth_error()}. +device_auth_flow(Config, ClientId, Scope, PromptUser) -> + device_auth_flow(Config, ClientId, Scope, PromptUser, []). + +%% @doc +%% Runs the complete OAuth device authorization flow with options. +%% +%% This function handles the entire device authorization flow: +%% 1. Requests a device code from the server +%% 2. Calls `PromptUser' callback with the verification URI and user code +%% 3. Optionally opens the browser for the user (when `open_browser' is true) +%% 4. Polls the token endpoint until authorization completes or times out +%% +%% The `PromptUser' callback is responsible for displaying the verification URI +%% and user code to the user (e.g., printing to console). +%% +%% Options: +%% * `name' - A name to identify the token (defaults to the machine's hostname) +%% * `open_browser' - When `true', automatically opens the browser +%% to the verification URI. When `false' (default), only the callback is invoked. +%% +%% Returns: +%% - `{ok, Tokens}' - Authorization successful, returns access token and optional refresh token +%% - `{error, timeout}' - Device code expired before user completed authorization +%% - `{error, {access_denied, Status, Body}}' - User denied the authorization request +%% - `{error, {device_auth_failed, Status, Body}}' - Initial device authorization request failed +%% - `{error, {poll_failed, Status, Body}}' - Unexpected error during polling +%% +%% Examples: +%% +%% ``` +%% 1> Config = mix_hex_core:default_config(). +%% 2> PromptUser = fun(Uri, Code) -> +%% io:format("Visit ~s and enter code: ~s~n", [Uri, Code]) +%% end. +%% 3> mix_hex_api_oauth:device_auth_flow(Config, <<"cli">>, <<"api:write">>, PromptUser). +%% {ok, #{ +%% access_token => <<"...">>, +%% refresh_token => <<"...">>, +%% expires_at => 1234567890 +%% }} +%% ''' +%% @end +-spec device_auth_flow( + mix_hex_core:config(), + ClientId :: binary(), + Scope :: binary(), + PromptUser :: fun((VerificationUri :: binary(), UserCode :: binary()) -> ok), + proplists:proplist() +) -> {ok, oauth_tokens()} | {error, device_auth_error()}. +device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) -> + case device_authorization(Config, ClientId, Scope, Opts) of + {ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) -> + #{ + <<"device_code">> := DeviceCode, + <<"user_code">> := UserCode, + <<"verification_uri_complete">> := VerificationUri, + <<"expires_in">> := ExpiresIn, + <<"interval">> := IntervalSeconds + } = DeviceResponse, + ok = PromptUser(VerificationUri, UserCode), + OpenBrowser = proplists:get_value(open_browser, Opts, false), + case OpenBrowser of + true -> open_browser(VerificationUri); + false -> ok + end, + ExpiresAt = erlang:system_time(second) + ExpiresIn, + poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt); + {ok, {Status, _, Body}} -> + {error, {device_auth_failed, Status, Body}}; + {error, Reason} -> + {error, Reason} + end. + +%% @private +poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) -> + Now = erlang:system_time(second), + case Now >= ExpiresAt of + true -> + {error, timeout}; + false -> + timer:sleep(IntervalSeconds * 1000), + case poll_device_token(Config, ClientId, DeviceCode) of + {ok, {200, _, TokenResponse}} when is_map(TokenResponse) -> + #{ + <<"access_token">> := AccessToken, + <<"expires_in">> := ExpiresIn + } = TokenResponse, + RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined), + TokenExpiresAt = erlang:system_time(second) + ExpiresIn, + {ok, #{ + access_token => AccessToken, + refresh_token => RefreshToken, + expires_at => TokenExpiresAt + }}; + {ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} -> + poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt); + {ok, {400, _, #{<<"error">> := <<"slow_down">>}}} -> + %% Increase polling interval as requested by server + poll_for_token_loop( + Config, ClientId, DeviceCode, IntervalSeconds + 5, ExpiresAt + ); + {ok, {400, _, #{<<"error">> := <<"expired_token">>}}} -> + {error, timeout}; + {ok, {Status, _, #{<<"error">> := <<"access_denied">>} = Body}} -> + {error, {access_denied, Status, Body}}; + {ok, {Status, _, Body}} -> + {error, {poll_failed, Status, Body}}; + {error, Reason} -> + {error, Reason} + end + end. + %% @doc %% Polls the OAuth token endpoint for device authorization completion. %% @@ -201,3 +342,48 @@ revoke_token(Config, ClientId, Token) -> <<"client_id">> => ClientId }, mix_hex_api:post(Config, Path, Params). + +%%==================================================================== +%% Internal functions +%%==================================================================== + +%% @private +%% Open a URL in the default browser. +%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows). +-spec open_browser(binary()) -> ok | {error, browser_not_found}. +open_browser(Url) when is_binary(Url) -> + ok = ensure_valid_http_url(Url), + UrlStr = binary_to_list(Url), + {Cmd, Args} = + case os:type() of + {unix, darwin} -> + {"open", [UrlStr]}; + {unix, _} -> + {"xdg-open", [UrlStr]}; + {win32, _} -> + {"cmd", ["/c", "start", "", UrlStr]} + end, + case os:find_executable(Cmd) of + false -> + {error, browser_not_found}; + Executable -> + open_port({spawn_executable, Executable}, [{args, Args}]), + ok + end. + +%% @private +%% Validates that a URL uses http:// or https:// scheme. +-spec ensure_valid_http_url(binary()) -> ok. +ensure_valid_http_url(Url) when is_binary(Url) -> + case uri_string:parse(Url) of + #{scheme := <<"https">>} -> ok; + #{scheme := <<"http">>} -> ok; + _ -> throw({invalid_url, Url}) + end. + +%% @private +%% Get the hostname of the current machine. +-spec get_hostname() -> binary(). +get_hostname() -> + {ok, Hostname} = inet:gethostname(), + list_to_binary(Hostname). diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index 0c4fe54e..fc38abac 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 3121eccd..05c5e3be 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.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 2ce96fc2..a4f64729 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 79f5500d..f8d4056f 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.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 d8c21396..5ff45a25 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 be4a71ac..c6fea0dd 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.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 2aa9558e..fb094974 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 new file mode 100644 index 00000000..80b6d827 --- /dev/null +++ b/src/mix_hex_cli_auth.erl @@ -0,0 +1,760 @@ +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually + +%% @doc +%% Authentication handling with callback functions for build-tool-specific operations. +%% +%% This module provides generic authentication handling that allows both rebar3 +%% and Elixir Hex (and future build tools) to share the common auth logic while +%% customizing prompting, persistence, and configuration retrieval. +%% +%% == Callbacks == +%% +%% Callbacks are provided via the `cli_auth_callbacks' key in the config map. +%% All callbacks are required: +%% +%% ``` +%% #{ +%% %% Auth configuration for a specific repo +%% get_auth_config => fun((RepoName :: binary()) -> +%% #{api_key => binary(), +%% auth_key => binary(), +%% oauth_exchange => boolean(), +%% oauth_exchange_url => binary()} | undefined), +%% +%% %% Global OAuth tokens - storage and retrieval +%% get_oauth_tokens => fun(() -> {ok, #{access_token := binary(), +%% refresh_token => binary(), +%% expires_at := integer()}} | error), +%% persist_oauth_tokens => fun((Scope :: global | binary(), +%% AccessToken :: binary(), +%% RefreshToken :: binary() | undefined, +%% ExpiresAt :: integer()) -> ok), +%% +%% %% User interaction +%% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), +%% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()), +%% +%% %% OAuth client configuration +%% get_client_id => fun(() -> binary()) +%% } +%% ''' +%% +%% == Auth Resolution Order == +%% +%% For API calls: +%%
    +%%
  1. Per-repo `api_key' from config (with optional OAuth exchange for hex.pm)
  2. +%%
  3. Parent repo `api_key' (for "hexpm:org" organizations)
  4. +%%
  5. Global OAuth token (refreshed if expired)
  6. +%%
  7. Device auth flow (for write operations only)
  8. +%%
+%% +%% For repo calls: +%%
    +%%
  1. Per-repo `auth_key' with optional OAuth exchange (default true for hex.pm)
  2. +%%
  3. Parent repo `auth_key'
  4. +%%
  5. Global OAuth token
  6. +%%
+%% +%% == OAuth Exchange == +%% +%% For hex.pm URLs, `api_key' and `auth_key' are exchanged for short-lived OAuth +%% tokens via the client credentials grant. This behavior can be controlled per-repo +%% via the `oauth_exchange' option in the repo config (defaults to `true' for hex.pm). +%% +%% == Auth Context == +%% +%% Internally, authentication resolution tracks context via `auth_context()': +%% +%% +%% == Token Format == +%% +%% OAuth access tokens are automatically prefixed with `<<"Bearer ">>' when used +%% as `api_key' or `repo_key' in the config. +-module(mix_hex_cli_auth). + +-export([ + with_api/3, + with_api/4, + with_repo/2, + with_repo/3, + resolve_api_auth/2, + resolve_repo_auth/1 +]). + +-export_type([ + callbacks/0, + permission/0, + auth_error/0, + auth_context/0, + repo_auth_config/0, + auth_prompt_reason/0, + opts/0 +]). + +%% 5 minute buffer before expiry +-define(EXPIRY_BUFFER_SECONDS, 300). + +%% Maximum OTP retry attempts +-define(MAX_OTP_RETRIES, 3). + +-type permission() :: read | write. + +-type callbacks() :: #{ + get_auth_config := fun((RepoName :: binary()) -> repo_auth_config() | undefined), + get_oauth_tokens := fun(() -> {ok, oauth_tokens()} | error), + persist_oauth_tokens := fun( + ( + Scope :: global | binary(), + AccessToken :: binary(), + RefreshToken :: binary() | undefined, + ExpiresAt :: integer() + ) -> ok + ), + prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), + should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()), + get_client_id := fun(() -> binary()) +}. + +-type auth_prompt_reason() :: + no_credentials + | token_refresh_failed. + +-type repo_auth_config() :: #{ + api_key => binary(), + repo_key => binary(), + auth_key => binary(), + oauth_token => oauth_tokens() +}. + +-type oauth_tokens() :: #{ + access_token := binary(), + refresh_token => binary(), + expires_at := integer() +}. + +-type auth_error() :: + {auth_error, no_credentials} + | {auth_error, auth_declined} + | {auth_error, otp_cancelled} + | {auth_error, otp_max_retries} + | {auth_error, token_refresh_failed} + | {auth_error, device_auth_timeout} + | {auth_error, device_auth_denied} + | {auth_error, oauth_exchange_failed} + | {auth_error, term()}. + +-type auth_context() :: #{ + source => env | config | oauth, + has_refresh_token => boolean() +}. + +-type opts() :: [ + {optional, boolean()} + | {auth_inline, boolean()} + | {oauth_open_browser, boolean()} +]. + +%%==================================================================== +%% API functions +%%==================================================================== + +%% @doc +%% Execute a function with API authentication. +%% +%% Equivalent to `with_api(Permission, Config, Fun, [])'. +%% +%% @see with_api/4 +-spec with_api(permission(), mix_hex_core:config(), fun((mix_hex_core:config()) -> Result)) -> + Result | {error, auth_error()} +when + Result :: term(). +with_api(Permission, BaseConfig, Fun) -> + with_api(Permission, BaseConfig, Fun, []). + +%% @doc +%% Execute a function with API authentication. +%% +%% Resolves credentials in this order: +%%
    +%%
  1. Per-repo `api_key' from config (with optional OAuth exchange for hex.pm)
  2. +%%
  3. Parent repo `api_key' (for "hexpm:org" organizations)
  4. +%%
  5. Global OAuth token (refreshed if expired)
  6. +%%
  7. Device auth flow (when `should_authenticate' callback returns true)
  8. +%%
+%% +%% On 401 responses, handles OTP prompts and token refresh automatically. +%% +%% The repository name is taken from the config (`repo_name' or `repo_organization'). +%% +%% Callbacks are taken from the `cli_auth_callbacks' key in the config map. +%% +%% Options: +%% +%% +%% Example: +%% ``` +%% mix_hex_cli_auth:with_api(write, Config, fun(C) -> +%% mix_hex_api_release:publish(C, Tarball) +%% end, [{optional, false}, {auth_inline, true}]). +%% ''' +-spec with_api( + permission(), + mix_hex_core:config(), + fun((mix_hex_core:config()) -> Result), + opts() +) -> + Result | {error, auth_error()} +when + Result :: term(). +with_api(Permission, BaseConfig, Fun, Opts) -> + Optional = proplists:get_value(optional, Opts, false), + AuthInline = proplists:get_value(auth_inline, Opts, true), + case resolve_api_auth(Permission, BaseConfig) of + {ok, ApiKey, AuthContext} -> + Config = BaseConfig#{api_key => ApiKey}, + execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); + {error, no_auth} when Optional =:= true -> + %% Auth is optional, try without credentials first + execute_optional_with_retry(BaseConfig, Fun, Opts); + {error, no_auth} when AuthInline =:= true -> + %% No auth found, ask user if they want to authenticate + maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); + {error, no_auth} -> + %% auth_inline is false, just return error + {error, {auth_error, no_credentials}}; + {error, {auth_error, token_refresh_failed}} when Optional =:= true -> + %% Token refresh failed but auth is optional, fall back to no credentials + execute_optional_with_retry(BaseConfig, Fun, Opts); + {error, _} = Error -> + Error + end. + +%% @doc +%% Execute a function with repository authentication. +%% +%% Equivalent to `with_repo(Config, Fun, [])'. +%% +%% @see with_repo/3 +-spec with_repo(mix_hex_core:config(), fun((mix_hex_core:config()) -> Result)) -> + Result | {error, auth_error()} +when + Result :: term(). +with_repo(BaseConfig, Fun) -> + with_repo(BaseConfig, Fun, []). + +%% @doc +%% Execute a function with repository authentication. +%% +%% Resolves credentials in this order: +%%
    +%%
  1. `repo_key' in config - passthrough
  2. +%%
  3. `repo_key' from `get_auth_config' callback - passthrough
  4. +%%
  5. `auth_key' from `get_auth_config' when `trusted' is true and `oauth_exchange' is true - exchange for OAuth token
  6. +%%
  7. `auth_key' from `get_auth_config' when `trusted' is true - use directly
  8. +%%
  9. Global OAuth token from `get_oauth_tokens' callback
  10. +%%
  11. No auth when `optional' is true (with retry on 401)
  12. +%%
  13. Prompt via `should_authenticate' when `auth_inline' is true
  14. +%%
+%% +%% The repository name is taken from the config (`repo_name' or `repo_organization'). +%% +%% Callbacks are taken from the `cli_auth_callbacks' key in the config map. +%% +%% Options: +%% +%% +%% Example: +%% ``` +%% mix_hex_cli_auth:with_repo(Config, fun(C) -> +%% mix_hex_repo:get_tarball(C, <<"ecto">>, <<"3.0.0">>) +%% end). +%% ''' +-spec with_repo(mix_hex_core:config(), fun((mix_hex_core:config()) -> Result), opts()) -> + Result | {error, auth_error()} +when + Result :: term(). +with_repo(BaseConfig, Fun, Opts) -> + Optional = proplists:get_value(optional, Opts, true), + AuthInline = proplists:get_value(auth_inline, Opts, false), + case resolve_repo_auth(BaseConfig) of + {ok, RepoKey, _AuthContext} when is_binary(RepoKey) -> + Config = BaseConfig#{repo_key => RepoKey}, + Fun(Config); + no_auth when Optional =:= true -> + %% Auth is optional, try without credentials first + execute_optional_with_retry(BaseConfig, Fun, Opts); + no_auth when AuthInline =:= true -> + %% No auth found, ask user if they want to authenticate + maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); + no_auth -> + %% auth_inline is false, return error + {error, {auth_error, no_credentials}}; + {error, {auth_error, token_refresh_failed}} when Optional =:= true -> + %% Token refresh failed but auth is optional, fall back to no credentials + execute_optional_with_retry(BaseConfig, Fun, Opts); + {error, _} = Error -> + Error + end. + +%% @private +%% Extract repository name from config. +-spec repo_name(mix_hex_core:config()) -> binary(). +repo_name(#{repo_name := Name, repo_organization := Org}) when is_binary(Name) and is_binary(Org) -> + <>; +repo_name(#{repo_name := Name}) when is_binary(Name) -> Name; +repo_name(_) -> + <<"hexpm">>. + +%% @private +%% Ask user if they want to authenticate, and if yes, initiate device auth. +%% +%% Serialized with a global lock so concurrent callers don't each trigger their +%% own device auth flow. The first caller to acquire the lock runs device auth +%% and persists the resulting token; subsequent callers re-check for an existing +%% (now-valid) token inside the lock and reuse it instead of re-authenticating. +maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) -> + global:trans( + {{?MODULE, device_auth}, self()}, + fun() -> + do_maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) + end, + [node()], + infinity + ). + +%% @private +%% Another caller may have authenticated while we waited for the lock. Re-resolve +%% and, if we get a token that differs from the one we arrived with (none when +%% credentials were missing; the rejected one on token_refresh_failed), reuse it +%% instead of prompting again. Otherwise proceed to prompt + device auth. +do_maybe_authenticate_and_retry(BaseConfig, Fun, Reason, Opts) -> + CurrentApiKey = maps:get(api_key, BaseConfig, undefined), + case resolve_api_auth(write, BaseConfig) of + {ok, ApiKey, AuthContext} when ApiKey =/= CurrentApiKey -> + Config = BaseConfig#{api_key => ApiKey}, + execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); + _ -> + prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) + end. + +%% @private +prompt_and_device_auth(BaseConfig, Fun, Reason, Opts) -> + case call_callback(BaseConfig, should_authenticate, [Reason]) of + true -> + case device_auth(BaseConfig, <<"api repositories">>, Opts) of + {ok, #{access_token := Token}} -> + BearerToken = <<"Bearer ", Token/binary>>, + Config = BaseConfig#{api_key => BearerToken}, + AuthContext = #{source => oauth, has_refresh_token => true}, + execute_with_retry(Config, Fun, AuthContext, 0, undefined, Opts); + {error, _} = Error -> + Error + end; + false -> + {error, {auth_error, auth_declined}} + end. + +%% @private +%% Execute function without auth, but retry with auth if we get a 401. +execute_optional_with_retry(BaseConfig, Fun, Opts) -> + AuthInline = proplists:get_value(auth_inline, Opts, true), + case Fun(BaseConfig) of + {ok, {401, _Headers, _Body}} when AuthInline =:= true -> + %% Got 401, need auth - ask user if they want to authenticate + maybe_authenticate_and_retry(BaseConfig, Fun, no_credentials, Opts); + {ok, {401, _Headers, _Body}} -> + %% Got 401 but auth_inline is false, return error + {error, {auth_error, no_credentials}}; + Other -> + Other + end. + +%%==================================================================== +%% Internal functions - Device Auth +%%==================================================================== + +%% @private +%% Initiate OAuth device authorization flow. +%% Prompts user, optionally opens the browser for user authentication, +%% polls for token completion, and persists tokens via callback on success. +-spec device_auth(mix_hex_core:config(), binary(), opts()) -> + {ok, oauth_tokens()} | {error, auth_error()}. +device_auth(Config, Scope, Opts) -> + ClientId = call_callback(Config, get_client_id, []), + OpenBrowser = proplists:get_value(oauth_open_browser, Opts, true), + PromptUser = fun(VerificationUri, UserCode) -> + io:format("Open ~ts in your browser and enter code: ~ts~n", [VerificationUri, UserCode]) + end, + FlowOpts = [{open_browser, OpenBrowser}], + case mix_hex_api_oauth:device_auth_flow(Config, ClientId, Scope, PromptUser, FlowOpts) of + {ok, #{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt}} -> + ok = call_callback(Config, persist_oauth_tokens, [ + global, AccessToken, RefreshToken, ExpiresAt + ]), + {ok, #{ + access_token => AccessToken, + refresh_token => RefreshToken, + expires_at => ExpiresAt + }}; + {error, timeout} -> + {error, {auth_error, device_auth_timeout}}; + {error, {access_denied, _Status, _Body}} -> + {error, {auth_error, device_auth_denied}}; + {error, {device_auth_failed, _Status, _Body} = Reason} -> + {error, {auth_error, Reason}}; + {error, {poll_failed, _Status, _Body} = Reason} -> + {error, {auth_error, Reason}}; + {error, Reason} -> + {error, {auth_error, Reason}} + end. + +%% @private +%% Check if a token is expired (within 5 minute buffer). +-spec is_token_expired(integer()) -> boolean(). +is_token_expired(ExpiresAt) -> + Now = erlang:system_time(second), + ExpiresAt - Now < ?EXPIRY_BUFFER_SECONDS. + +%%==================================================================== +%% Internal functions - Auth Resolution +%%==================================================================== + +%% @private +-spec resolve_api_auth(permission(), mix_hex_core:config()) -> + {ok, binary(), auth_context()} | {error, no_auth} | {error, auth_error()}. +resolve_api_auth(_Permission, #{api_key := ApiKey}) when is_binary(ApiKey) -> + %% api_key already in config, pass through directly + {ok, ApiKey, #{source => config, has_refresh_token => false}}; +resolve_api_auth(_Permission, Config) -> + RepoName = repo_name(Config), + %% 1. Check per-repo api_key + case call_callback(Config, get_auth_config, [RepoName]) of + #{api_key := ApiKey} when is_binary(ApiKey) -> + {ok, ApiKey, #{source => config, has_refresh_token => false}}; + _ -> + %% 2. Check parent repo (for "hexpm:org" organizations) + case get_parent_repo_key(Config, RepoName, api_key) of + {ok, ApiKey} -> + {ok, ApiKey, #{source => config, has_refresh_token => false}}; + error -> + %% 3. Try global OAuth token + resolve_oauth_token_with_context(Config) + end + end. + +%% @private +%% Resolve repo auth credentials in this order: +%% 0. repo_key in config => passthrough +%% 1. repo_key from get_auth_config => passthrough +%% 2. trusted + auth_key + oauth_exchange => exchange for OAuth token +%% 3. trusted + auth_key => use directly +%% 4. trusted + global OAuth tokens => use those +%% 5. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) +-spec resolve_repo_auth(mix_hex_core:config()) -> + {ok, binary(), auth_context()} | no_auth | {error, auth_error()}. +resolve_repo_auth(#{repo_key := RepoKey}) when is_binary(RepoKey) -> + %% repo_key already in config, pass through directly + {ok, RepoKey, #{source => config, has_refresh_token => false}}; +resolve_repo_auth(Config) -> + RepoName = repo_name(Config), + global:trans( + {{?MODULE, repo, RepoName}, self()}, + fun() -> + do_resolve_repo_auth(RepoName, RepoName, Config) + end, + [node()], + infinity + ). + +do_resolve_repo_auth(RepoName, LookupRepo, Config) -> + Trusted = maps:get(trusted, Config, false), + OAuthExchange = maps:get(oauth_exchange, Config, false), + case call_callback(Config, get_auth_config, [LookupRepo]) of + #{repo_key := RepoKey} when is_binary(RepoKey) -> + %% 1. repo_key from get_auth_config => passthrough + {ok, RepoKey, #{source => config, has_refresh_token => false}}; + #{oauth_token := OAuthToken, auth_key := AuthKey} when + is_binary(AuthKey) and OAuthExchange, Trusted + -> + %% 2. trusted + oauth_token + auth_key + oauth_exchange => use/refresh existing token + resolve_repo_oauth_token(RepoName, Config, AuthKey, OAuthToken); + #{auth_key := AuthKey} when is_binary(AuthKey) and OAuthExchange, Trusted -> + %% 3. trusted + auth_key + oauth_exchange => exchange for new OAuth token + exchange_for_oauth_token(RepoName, Config, AuthKey, <<"repositories">>); + #{auth_key := AuthKey} when is_binary(AuthKey), Trusted -> + %% 4. trusted + auth_key => use directly + {ok, AuthKey, #{source => config, has_refresh_token => false}}; + _ when Trusted -> + %% 5. Check parent repo (for "hexpm:org" organizations) + case binary:split(LookupRepo, <<":">>) of + [ParentName, _OrgName] -> + do_resolve_repo_auth(RepoName, ParentName, Config); + _ -> + %% 6. trusted + global OAuth tokens => use those + resolve_global_oauth_for_repo(Config) + end; + _ -> + %% 7. Not trusted, no auth + no_auth + end. + +%% @private +resolve_global_oauth_for_repo(Config) -> + case resolve_oauth_token_with_context(Config) of + {ok, Token, AuthContext} -> + {ok, Token, AuthContext}; + {error, no_auth} -> + no_auth; + {error, _} = Error -> + Error + end. + +%% @private +%% Resolve repo OAuth token: use if valid, re-exchange if expiring. +resolve_repo_oauth_token(RepoName, Config, AuthKey, #{ + access_token := AccessToken, expires_at := ExpiresAt +}) -> + case is_token_expired(ExpiresAt) of + false -> + %% Token is still valid, use it + BearerToken = <<"Bearer ", AccessToken/binary>>, + {ok, BearerToken, #{source => oauth, has_refresh_token => false}}; + true -> + %% Token expired, do a new exchange + exchange_for_oauth_token(RepoName, Config, AuthKey, <<"repositories">>) + end. + +%% @private +%% Exchange api_key/auth_key for OAuth token via client credentials grant. +%% Persists the token with the repo name for per-repo token storage. +exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) -> + ClientId = call_callback(Config, get_client_id, []), + ExchangeConfig = + case maps:get(oauth_exchange_url, Config, undefined) of + undefined -> Config; + OAuthUrl -> Config#{api_url => OAuthUrl} + end, + case mix_hex_api_oauth:client_credentials_token(ExchangeConfig, ClientId, AuthKey, Scope) of + {ok, {200, _, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} -> + ExpiresAt = erlang:system_time(second) + ExpiresIn, + ok = call_callback(Config, persist_oauth_tokens, [ + RepoName, AccessToken, undefined, ExpiresAt + ]), + BearerToken = <<"Bearer ", AccessToken/binary>>, + {ok, BearerToken, #{source => oauth, has_refresh_token => false}}; + {ok, {_Status, _, _Body}} -> + {error, {auth_error, oauth_exchange_failed}}; + {error, _} -> + {error, {auth_error, oauth_exchange_failed}} + end. + +%% @private +get_parent_repo_key(Config, RepoName, KeyType) -> + case binary:split(RepoName, <<":">>) of + [ParentName, _OrgName] -> + case call_callback(Config, get_auth_config, [ParentName]) of + #{KeyType := Key} when is_binary(Key) -> + {ok, Key}; + _ -> + error + end; + _ -> + error + end. + +%% @private +%% Resolve OAuth token with global lock to prevent concurrent refresh attempts. +resolve_oauth_token_with_context(Config) -> + global:trans( + {{?MODULE, token_refresh}, self()}, + fun() -> + do_resolve_oauth_token_with_context(Config) + end, + [node()], + infinity + ). + +%% @private +do_resolve_oauth_token_with_context(Config) -> + case call_callback(Config, get_oauth_tokens, []) of + {ok, #{access_token := AccessToken, expires_at := ExpiresAt} = Tokens} -> + HasRefreshToken = + maps:is_key(refresh_token, Tokens) andalso + is_binary(maps:get(refresh_token, Tokens)), + case is_token_expired(ExpiresAt) of + true -> + maybe_refresh_token_with_context(Config, Tokens); + false -> + BearerToken = <<"Bearer ", AccessToken/binary>>, + {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}} + end; + error -> + {error, no_auth} + end. + +%% @private +maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when + is_binary(RefreshToken) +-> + ClientId = call_callback(Config, get_client_id, []), + case mix_hex_api_oauth:refresh_token(Config, ClientId, RefreshToken) of + {ok, {200, _, TokenResponse}} when is_map(TokenResponse) -> + #{ + <<"access_token">> := NewAccessToken, + <<"expires_in">> := ExpiresIn + } = TokenResponse, + NewRefreshToken = maps:get(<<"refresh_token">>, TokenResponse, RefreshToken), + ExpiresAt = erlang:system_time(second) + ExpiresIn, + ok = call_callback(Config, persist_oauth_tokens, [ + global, NewAccessToken, NewRefreshToken, ExpiresAt + ]), + BearerToken = <<"Bearer ", NewAccessToken/binary>>, + HasRefreshToken = is_binary(NewRefreshToken), + {ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}}; + {ok, {_Status, _, _Body}} -> + {error, {auth_error, token_refresh_failed}}; + {error, _Reason} -> + {error, {auth_error, token_refresh_failed}} + end; +maybe_refresh_token_with_context(_Config, _Tokens) -> + {error, {auth_error, token_refresh_failed}}. + +%%==================================================================== +%% Internal functions - Retry Logic +%%==================================================================== + +%% @private +execute_with_retry(Config, Fun, AuthContext, OtpRetries, LastOtpError, Opts) -> + case Fun(Config) of + {error, otp_required} -> + handle_otp_retry( + Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts + ); + {error, invalid_totp} -> + handle_otp_retry( + Config, + Fun, + AuthContext, + OtpRetries, + <<"Invalid OTP code. Please try again:">>, + Opts + ); + {ok, {401, Headers, _Body}} = Response -> + case detect_auth_error(Headers) of + otp_required -> + handle_otp_retry( + Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">>, Opts + ); + invalid_totp -> + Msg = + case LastOtpError of + invalid_totp -> <<"Invalid OTP code. Please try again:">>; + _ -> <<"Enter OTP code:">> + end, + handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Msg, Opts); + token_expired -> + handle_token_refresh_retry(Config, Fun, AuthContext, Opts); + none -> + Response + end; + Other -> + Other + end. + +%% @private +handle_otp_retry(_Config, _Fun, _AuthContext, OtpRetries, _Message, _Opts) when + OtpRetries >= ?MAX_OTP_RETRIES +-> + {error, {auth_error, otp_max_retries}}; +handle_otp_retry(Config, Fun, AuthContext, OtpRetries, Message, Opts) -> + case call_callback(Config, prompt_otp, [Message]) of + {ok, OtpCode} -> + NewConfig = Config#{api_otp => OtpCode}, + execute_with_retry( + NewConfig, Fun, AuthContext, OtpRetries + 1, invalid_totp, Opts + ); + cancelled -> + {error, {auth_error, otp_cancelled}} + end. + +%% @private +handle_token_refresh_retry(Config, Fun, AuthContext, Opts) -> + %% Only attempt refresh if we have a refresh token + case maps:get(has_refresh_token, AuthContext, false) of + true -> + case resolve_oauth_token_with_context(Config) of + {ok, NewBearerToken, NewAuthContext} -> + NewConfig = Config#{api_key => NewBearerToken}, + execute_with_retry( + NewConfig, Fun, NewAuthContext, 0, undefined, Opts + ); + {error, _} -> + maybe_reauthenticate(Config, Fun, Opts) + end; + false -> + maybe_reauthenticate(Config, Fun, Opts) + end. + +%% @private +%% After token refresh failure, prompt the user to re-authenticate via device auth +%% (only when auth_inline is true). Mirrors Hex.OAuth.reauthenticate/1. +maybe_reauthenticate(Config, Fun, Opts) -> + AuthInline = proplists:get_value(auth_inline, Opts, true), + case AuthInline of + true -> + maybe_authenticate_and_retry(Config, Fun, token_refresh_failed, Opts); + false -> + {error, {auth_error, token_refresh_failed}} + end. + +%% @private +-spec detect_auth_error(mix_hex_http:headers()) -> otp_required | invalid_totp | token_expired | none. +detect_auth_error(Headers) -> + case maps:get(<<"www-authenticate">>, Headers, undefined) of + undefined -> + none; + Value -> + parse_www_authenticate(Value) + end. + +%% @private +parse_www_authenticate(Value) when is_binary(Value) -> + case Value of + <<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> -> + otp_required; + <<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> -> + invalid_totp; + <<"Bearer realm=\"hex\", error=\"token_expired\"", _/binary>> -> + token_expired; + _ -> + none + end. + +%%==================================================================== +%% Internal functions - Utilities +%%==================================================================== + +%% @private +call_callback(Config, Name, Args) -> + #{cli_auth_callbacks := Callbacks} = Config, + Fun = maps:get(Name, Callbacks), + erlang:apply(Fun, Args). diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index c5277517..d0db23f2 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% `hex_core' entrypoint module. @@ -125,7 +125,11 @@ tarball_max_uncompressed_size => pos_integer() | infinity, docs_tarball_max_size => pos_integer() | infinity, docs_tarball_max_uncompressed_size => pos_integer() | infinity, - metadata_fields => all | [binary()] + metadata_fields => all | [binary()], + trusted => boolean(), + oauth_exchange => boolean(), + oauth_exchange_url => binary() | undefined, + cli_auth_callbacks => mix_hex_cli_auth:callbacks() | undefined }. -spec default_config() -> config(). @@ -153,5 +157,9 @@ default_config() -> tarball_max_uncompressed_size => 128 * 1024 * 1024, docs_tarball_max_size => 16 * 1024 * 1024, docs_tarball_max_uncompressed_size => 128 * 1024 * 1024, - metadata_fields => all + metadata_fields => all, + trusted => true, + oauth_exchange => true, + oauth_exchange_url => undefined, + cli_auth_callbacks => undefined }. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index 90c3d7de..711c5e50 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually --define(HEX_CORE_VERSION, "0.17.0"). +-define(HEX_CORE_VERSION, "0.18.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index 7bac331c..ffb1ea04 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 1ac7835c..5328b1cf 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 4f0b0dbd..574f1e7c 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index a04eb8bc..c68acf9c 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 2ba8dfe5..24c80834 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index 041ded9b..d17b0554 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 baa37340..9f3ff7c4 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 3edb295b..de574686 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 26511274..47ba063e 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 aba0a838..9556adb2 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. @@ -16,6 +16,10 @@ decode_package/3, build_package/2, unpack_package/4, + encode_policy/1, + decode_policy/3, + build_policy/2, + unpack_policy/4, sign_protobuf/2, decode_signed/1, decode_and_verify_signed/2, @@ -118,6 +122,35 @@ decode_package(Payload, Repository, Package) -> {error, bad_repo_name} end. +%% @doc +%% Builds policy resource. +build_policy(Policy, PrivateKey) -> + Payload = encode_policy(Policy), + zlib:gzip(sign_protobuf(Payload, PrivateKey)). + +%% @doc +%% Unpacks policy resource. +unpack_policy(Payload, Repository, Name, PublicKey) -> + case decode_and_verify_signed(zlib:gunzip(Payload), PublicKey) of + {ok, Policy} -> decode_policy(Policy, Repository, Name); + Other -> Other + end. + +%% @private +encode_policy(Policy) -> + hex_pb_policy:encode_msg(Policy, 'Policy'). + +%% @private +decode_policy(Payload, no_verify, no_verify) -> + {ok, hex_pb_policy:decode_msg(Payload, 'Policy')}; +decode_policy(Payload, Repository, Name) -> + case hex_pb_policy:decode_msg(Payload, 'Policy') of + #{repository := Repository, name := Name} = Result -> + {ok, Result}; + _ -> + {error, bad_repo_name} + end. + %% @private sign_protobuf(Payload, PrivateKey) -> Signature = sign(Payload, PrivateKey), diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index fc6fc99a..75dedce5 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %% @doc %% Repo API. @@ -7,6 +7,7 @@ get_names/1, get_versions/1, get_package/2, + get_policy/2, get_tarball/3, get_tarball_to_file/4, get_docs/3, @@ -90,6 +91,40 @@ get_package(Config, Name) when is_binary(Name) and is_map(Config) -> end, get_protobuf(Config, <<"packages/", Name/binary>>, Decoder). +%% @doc +%% Gets policy resource from the repository. +%% +%% Requires `repo_organization' to be set in the config; policies are +%% always served from the per-organization namespace +%% (`/repos//policies/'). Returns +%% `{error, missing_repo_organization}' when it is not set. +%% +%% Examples: +%% +%% ``` +%% > Config = (mix_hex_core:default_config())#{repo_organization => <<"myorg">>}, +%% > mix_hex_repo:get_policy(Config, <<"strict-prod">>). +%% {ok, {200, ..., +%% #{repository => <<"myorg">>, +%% name => <<"strict-prod">>, +%% visibility => 'VISIBILITY_PUBLIC'}}} +%% ''' +%% @end +get_policy(Config, Name) when is_binary(Name) and is_map(Config) -> + case maps:get(repo_organization, Config, undefined) of + undefined -> + {error, missing_repo_organization}; + Org when is_binary(Org) -> + Verify = maps:get(repo_verify_origin, Config, true), + Decoder = fun(Data) -> + case Verify of + true -> mix_hex_registry:decode_policy(Data, Org, Name); + false -> mix_hex_registry:decode_policy(Data, no_verify, no_verify) + end + end, + get_protobuf(Config, <<"policies/", Name/binary>>, Decoder) + end. + %% @doc %% Gets tarball from the repository. %% diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index b8bfe0d2..a98181d4 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.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 242befcd..63fdc159 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), 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 ef79a5c2..2c12bb81 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.17.0 (cadf1b8), do not edit manually +%% Vendored from hex_core v0.18.0 (e093545), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. diff --git a/test/hex/api/client_test.exs b/test/hex/api/client_test.exs new file mode 100644 index 00000000..300c98a6 --- /dev/null +++ b/test/hex/api/client_test.exs @@ -0,0 +1,29 @@ +defmodule Hex.API.ClientTest do + use HexTest.Case + + alias Hex.API.Client + + describe "config/1 OTP handling" do + test "uses the OTP passed in opts" do + assert Client.config(otp: "123456").api_otp == "123456" + end + + test "falls back to HEX_OTP (api_otp state) for non-interactive use" do + Hex.State.put(:api_otp, "654321") + + assert Client.config().api_otp == "654321" + end + + test "prefers an explicit opts OTP over the api_otp state" do + Hex.State.put(:api_otp, "654321") + + assert Client.config(otp: "123456").api_otp == "123456" + end + + test "leaves api_otp undefined when no OTP is available" do + Hex.State.put(:api_otp, nil) + + assert Client.config().api_otp == :undefined + end + end +end diff --git a/test/hex/api/oauth_test.exs b/test/hex/api/oauth_test.exs deleted file mode 100644 index 565206f9..00000000 --- a/test/hex/api/oauth_test.exs +++ /dev/null @@ -1,219 +0,0 @@ -defmodule Hex.API.OAuthTest do - use HexTest.IntegrationCase, async: true - - # Using real test server at localhost:4043 with OAuth client configured - - describe "device_authorization/1" do - test "returns device authorization data" do - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api repositories") - - # Verify the response has the expected structure from the real server - assert is_binary(response["device_code"]) - assert is_binary(response["user_code"]) - assert is_binary(response["verification_uri"]) - assert is_integer(response["expires_in"]) - assert is_integer(response["interval"]) - end - - test "defaults to api repositories scope" do - assert {:ok, {200, _headers, response}} = Hex.API.OAuth.device_authorization("api") - - # Should return valid device authorization data - assert is_binary(response["device_code"]) - assert is_binary(response["user_code"]) - end - - test "handles invalid scope" do - # The real server should handle invalid scopes - may accept or reject - assert {:ok, {status, _headers, _response}} = - Hex.API.OAuth.device_authorization("invalid_scope") - - # Server may return 200 (accepted), 400 (invalid scope), or 401 (invalid client) - assert status in [200, 400, 401] - end - - test "sends name parameter when provided" do - name = "TestMachine" - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api repositories", name) - - # Verify the response has the expected structure - assert is_binary(response["device_code"]) - assert is_binary(response["user_code"]) - end - - test "works without name parameter" do - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api repositories", nil) - - # Should still return valid device authorization data - assert is_binary(response["device_code"]) - assert is_binary(response["user_code"]) - end - end - - describe "poll_device_token/1" do - test "returns authorization_pending for valid device code" do - # First get a valid device code - {:ok, {200, _headers, device_response}} = Hex.API.OAuth.device_authorization("api") - device_code = device_response["device_code"] - - # Polling should return authorization_pending since user hasn't authorized - assert {:ok, {400, _headers, %{"error" => "authorization_pending"}}} = - Hex.API.OAuth.poll_device_token(device_code) - end - - test "returns invalid_grant for invalid device code" do - assert {:ok, {400, _headers, %{"error" => "invalid_grant"}}} = - Hex.API.OAuth.poll_device_token("invalid_device_code") - end - - test "handles malformed device code" do - assert {:ok, {400, _headers, %{"error" => error}}} = - Hex.API.OAuth.poll_device_token("") - - assert error in ["invalid_grant", "invalid_request"] - end - end - - describe "refresh_token/1" do - test "handles invalid refresh token" do - # Test with a completely invalid refresh token - assert {:ok, {status, _headers, %{"error" => error}}} = - Hex.API.OAuth.refresh_token("invalid_refresh_token") - - assert status in [400, 401] - assert error in ["invalid_token", "invalid_grant"] - end - - test "handles malformed refresh token" do - # Test with malformed refresh token - assert {:ok, {status, _headers, %{"error" => error}}} = - Hex.API.OAuth.refresh_token("malformed_token") - - assert status in [400, 401] - assert error in ["invalid_token", "invalid_grant"] - end - - test "handles empty refresh token" do - assert {:ok, {400, _headers, %{"error" => error}}} = - Hex.API.OAuth.refresh_token("") - - assert error in ["invalid_grant", "invalid_request"] - end - end - - describe "revoke_token/1" do - test "returns 200 for token revocation" do - # OAuth revoke endpoint returns 200 even for invalid tokens (per RFC 7009) - assert {:ok, {200, _headers, _body}} = Hex.API.OAuth.revoke_token("any_token") - end - - test "handles empty token" do - assert {:ok, {200, _headers, _body}} = Hex.API.OAuth.revoke_token("") - end - end - - describe "exchange_api_key/3" do - test "exchanges valid API key for OAuth access token" do - auth = HexTest.Hexpm.new_user("apikey_user", "apikey@example.com", "password", "api_key") - api_key = auth[:key] - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.exchange_api_key(api_key, "api") - - assert is_binary(response["access_token"]) - assert response["token_type"] == "bearer" - assert is_integer(response["expires_in"]) - assert response["expires_in"] > 0 - assert response["scope"] == "api" - refute Map.has_key?(response, "refresh_token") - end - - test "exchanges API key with multiple scopes" do - {:ok, {201, _, _}} = - Hex.API.User.new("apikey_multi", "apikey_multi@example.com", "password") - - permissions = [%{"domain" => "api"}, %{"domain" => "repositories"}] - - {:ok, {201, _, %{"secret" => api_key}}} = - Hex.API.Key.new("api_key_multi", permissions, user: "apikey_multi", pass: "password") - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.exchange_api_key(api_key, "api repositories") - - assert is_binary(response["access_token"]) - assert response["token_type"] == "bearer" - assert response["scope"] == "api repository:hexpm" - end - - test "accepts scopes as list" do - {:ok, {201, _, _}} = Hex.API.User.new("apikey_list", "apikey_list@example.com", "password") - - permissions = [%{"domain" => "api"}, %{"domain" => "repositories"}] - - {:ok, {201, _, %{"secret" => api_key}}} = - Hex.API.Key.new("api_key_list", permissions, user: "apikey_list", pass: "password") - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.exchange_api_key(api_key, ["api", "repositories"]) - - assert is_binary(response["access_token"]) - assert response["scope"] == "api repository:hexpm" - end - - test "sends name parameter when provided" do - auth = - HexTest.Hexpm.new_user( - "apikey_named", - "apikey_named@example.com", - "password", - "api_key_named" - ) - - api_key = auth[:key] - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.exchange_api_key(api_key, "api", "TestMachine") - - assert is_binary(response["access_token"]) - end - - test "works without name parameter" do - auth = - HexTest.Hexpm.new_user( - "apikey_noname", - "apikey_noname@example.com", - "password", - "api_key_noname" - ) - - api_key = auth[:key] - - assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.exchange_api_key(api_key, "api", nil) - - assert is_binary(response["access_token"]) - end - - test "returns error for invalid API key" do - assert {:ok, {401, _headers, response}} = - Hex.API.OAuth.exchange_api_key("invalid_api_key", "api") - - assert is_map(response) - assert Map.has_key?(response, "message") or Map.has_key?(response, "error") - end - - test "returns error for empty API key" do - assert {:ok, {400, _headers, _response}} = - Hex.API.OAuth.exchange_api_key("", "api") - end - - test "handles malformed API key" do - assert {:ok, {401, _headers, _response}} = - Hex.API.OAuth.exchange_api_key("malformed-key", "api") - end - end -end diff --git a/test/hex/auth_test.exs b/test/hex/auth_test.exs new file mode 100644 index 00000000..65625854 --- /dev/null +++ b/test/hex/auth_test.exs @@ -0,0 +1,54 @@ +defmodule Hex.AuthTest do + use HexTest.Case + + defp get_auth_config(repo) do + Hex.Auth.callbacks().get_auth_config.(repo) + end + + defp put_repo(name, config) do + Hex.State.update!(:repos, &Map.put(&1, name, config)) + end + + describe "get_auth_config/1 callback" do + test "returns the repo config when no HEX_API_KEY is set" do + put_repo("acme", %{url: "https://acme.example", auth_key: "repo_key"}) + Hex.State.put(:api_key, nil) + + config = get_auth_config("acme") + + assert config.auth_key == "repo_key" + refute Map.has_key?(config, :api_key) + end + + test "surfaces HEX_API_KEY alongside the repo's own credentials" do + # API and repo credentials authenticate different endpoints, so the env + # api_key must not displace the repo's auth_key/oauth_token. + put_repo("acme", %{url: "https://acme.example", auth_key: "repo_key"}) + Hex.State.put(:api_key, "env_api_key") + + config = get_auth_config("acme") + + assert config.api_key == "env_api_key" + assert config.auth_key == "repo_key" + end + + test "does not override a per-repo api_key with HEX_API_KEY" do + put_repo("acme", %{url: "https://acme.example", api_key: "repo_api_key"}) + Hex.State.put(:api_key, "env_api_key") + + assert get_auth_config("acme").api_key == "repo_api_key" + end + + test "returns just the api_key when the repo is unknown" do + Hex.State.put(:api_key, "env_api_key") + + assert get_auth_config("does-not-exist") == %{api_key: "env_api_key"} + end + + test "returns :undefined when the repo is unknown and no HEX_API_KEY is set" do + Hex.State.put(:api_key, nil) + + assert get_auth_config("does-not-exist") == :undefined + end + end +end diff --git a/test/hex/config_test.exs b/test/hex/config_test.exs index 800cd48b..fe093883 100644 --- a/test/hex/config_test.exs +++ b/test/hex/config_test.exs @@ -1,5 +1,5 @@ defmodule Hex.ConfigTest do - use ExUnit.Case + use HexTest.Case alias Hex.Config test "find_config_home/1 when no env var flags are set" do @@ -38,4 +38,40 @@ defmodule Hex.ConfigTest do System.delete_env("HEX_HOME") System.delete_env("MIX_XDG") end + + test "read/0 migrates string-keyed OAuth tokens to atom keys" do + in_tmp(fn -> + set_home_cwd() + + Config.write( + "$oauth_token": %{ + "access_token" => "a_token", + "refresh_token" => "r_token", + "expires_at" => 123 + }, + "$repos": %{ + "hexpm:org" => %{ + url: "https://example.com", + oauth_token: %{ + "access_token" => "repo_token", + "expires_at" => 456 + } + } + } + ) + + config = Config.read() + + assert config[:"$oauth_token"] == %{ + access_token: "a_token", + refresh_token: "r_token", + expires_at: 123 + } + + assert config[:"$repos"]["hexpm:org"].oauth_token == %{ + access_token: "repo_token", + expires_at: 456 + } + end) + end end diff --git a/test/hex/oauth_test.exs b/test/hex/oauth_test.exs deleted file mode 100644 index 54a6f7ef..00000000 --- a/test/hex/oauth_test.exs +++ /dev/null @@ -1,297 +0,0 @@ -defmodule Hex.OAuthTest do - use HexTest.IntegrationCase - - describe "get_token/0" do - test "returns error when no tokens are stored" do - assert {:error, :no_auth} = Hex.OAuth.get_token() - end - - test "returns valid token when available and not expired" do - future_time = System.system_time(:second) + 3600 - - token_data = %{ - "access_token" => "test_token", - "refresh_token" => "test_refresh", - "expires_at" => future_time - } - - Hex.OAuth.store_token(token_data) - - assert {:ok, "test_token"} = Hex.OAuth.get_token() - end - - test "returns error when token is expired and no refresh possible" do - past_time = System.system_time(:second) - 100 - - token_data = %{ - "access_token" => "expired_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(token_data) - - assert {:error, :no_refresh_token} = Hex.OAuth.get_token() - assert_received {:mix_shell, :info, [warning]} - assert warning =~ "could not be refreshed" - assert warning =~ "mix hex.user auth" - end - - test "returns error when token is expired and refresh fails" do - past_time = System.system_time(:second) - 100 - - token_data = %{ - "access_token" => "expired_token", - "refresh_token" => "invalid_refresh_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(token_data) - - # Should fail to refresh and return error - assert {:error, :refresh_failed} = Hex.OAuth.get_token() - assert_received {:mix_shell, :info, [warning]} - assert warning =~ "could not be refreshed" - assert warning =~ "mix hex.user auth" - end - - test "warns only once when the cached refresh failure is hit repeatedly" do - past_time = System.system_time(:second) - 100 - - token_data = %{ - "access_token" => "expired_token", - "refresh_token" => "invalid_refresh_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(token_data) - - assert {:error, :refresh_failed} = Hex.OAuth.get_token() - assert {:error, :refresh_failed} = Hex.OAuth.get_token() - - assert_received {:mix_shell, :info, [warning]} - assert warning =~ "could not be refreshed" - refute_received {:mix_shell, :info, _} - end - - test "does not warn when no tokens are stored" do - assert {:error, :no_auth} = Hex.OAuth.get_token() - refute_received {:mix_shell, :info, _} - end - end - - describe "store_token/1" do - test "stores token in both config and state" do - token_data = %{ - "access_token" => "test_token", - "refresh_token" => "test_refresh", - "expires_at" => System.system_time(:second) + 3600 - } - - Hex.OAuth.store_token(token_data) - - # Check state - assert Hex.State.get(:oauth_token) == token_data - - # Check config - config = Hex.Config.read() - assert config[:"$oauth_token"] == token_data - end - - test "handles empty token" do - Hex.OAuth.store_token(%{}) - - assert Hex.State.get(:oauth_token) == %{} - config = Hex.Config.read() - assert config[:"$oauth_token"] == %{} - end - end - - describe "clear_tokens/0" do - test "removes tokens from both config and state" do - token_data = %{ - "access_token" => "token", - "refresh_token" => "refresh", - "expires_at" => System.system_time(:second) + 3600 - } - - Hex.OAuth.store_token(token_data) - assert Hex.OAuth.has_tokens?() - - Hex.OAuth.clear_tokens() - - assert Hex.State.get(:oauth_token) == nil - refute Hex.OAuth.has_tokens?() - end - - test "clears tokens from config file" do - token_data = %{ - "access_token" => "config_token", - "refresh_token" => "config_refresh", - "expires_at" => System.system_time(:second) + 3600 - } - - Hex.OAuth.store_token(token_data) - - # Verify token is in config - config = Hex.Config.read() - assert config[:"$oauth_token"]["access_token"] == "config_token" - - Hex.OAuth.clear_tokens() - - # Verify token is removed from config - config = Hex.Config.read() - refute config[:"$oauth_token"] - end - end - - describe "has_tokens?/0" do - test "returns false when no tokens are stored" do - refute Hex.OAuth.has_tokens?() - end - - test "returns true when tokens are stored" do - token_data = %{ - "access_token" => "token", - "refresh_token" => "refresh", - "expires_at" => System.system_time(:second) + 3600 - } - - Hex.OAuth.store_token(token_data) - assert Hex.OAuth.has_tokens?() - end - - test "returns true even with expired tokens" do - past_time = System.system_time(:second) - 100 - - token_data = %{ - "access_token" => "expired_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(token_data) - assert Hex.OAuth.has_tokens?() - end - end - - describe "create_token_data/1" do - test "creates token data with proper expiration time" do - current_time = System.system_time(:second) - - oauth_response = %{ - "access_token" => "test_token", - "refresh_token" => "test_refresh", - "expires_in" => 3600, - "token_type" => "bearer", - "scope" => "api" - } - - token_data = Hex.OAuth.create_token_data(oauth_response) - - assert token_data["access_token"] == "test_token" - assert token_data["refresh_token"] == "test_refresh" - assert token_data["expires_at"] >= current_time + 3600 - # Allow 5 second margin - assert token_data["expires_at"] <= current_time + 3600 + 5 - - # Should only contain the three required fields - assert Map.keys(token_data) |> Enum.sort() == [ - "access_token", - "expires_at", - "refresh_token" - ] - end - - test "handles missing refresh token" do - oauth_response = %{ - "access_token" => "test_token", - "expires_in" => 3600, - "token_type" => "bearer", - "scope" => "api" - } - - token_data = Hex.OAuth.create_token_data(oauth_response) - - assert token_data["access_token"] == "test_token" - refute Map.has_key?(token_data, "refresh_token") - assert is_integer(token_data["expires_at"]) - end - end - - describe "refresh_token/0" do - test "returns error when no refresh token available" do - token_data = %{ - "access_token" => "token_without_refresh", - "expires_at" => System.system_time(:second) + 100 - } - - Hex.OAuth.store_token(token_data) - - assert {:error, :no_refresh_token} = Hex.OAuth.refresh_token() - end - - test "returns error when no tokens stored" do - assert {:error, :no_auth} = Hex.OAuth.refresh_token() - end - end - - describe "concurrent token refresh" do - test "handles multiple concurrent get_token calls with expired token" do - # Store an expired token with no refresh token - # This simulates the race condition scenario where multiple processes - # try to get the token at the same time - past_time = System.system_time(:second) - 100 - - token_data = %{ - "access_token" => "expired_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(token_data) - - # Spawn multiple concurrent tasks that all try to get the token - tasks = - for _ <- 1..10 do - Task.async(fn -> - Hex.OAuth.get_token() - end) - end - - # Wait for all tasks to complete - results = Task.await_many(tasks) - - # All should fail since there's no refresh token, but they should all - # return the same error and not crash - assert Enum.all?(results, fn result -> - result == {:error, :no_refresh_token} - end) - end - - test "handles concurrent get_token calls with valid token" do - # Store a valid token - future_time = System.system_time(:second) + 3600 - - token_data = %{ - "access_token" => "valid_token", - "refresh_token" => "refresh_token", - "expires_at" => future_time - } - - Hex.OAuth.store_token(token_data) - - # Spawn multiple concurrent tasks - tasks = - for _ <- 1..10 do - Task.async(fn -> - Hex.OAuth.get_token() - end) - end - - # All should succeed - results = Task.await_many(tasks) - - assert Enum.all?(results, fn result -> - result == {:ok, "valid_token"} - end) - end - end -end diff --git a/test/hex/once_cache_test.exs b/test/hex/once_cache_test.exs deleted file mode 100644 index 3bdbab5c..00000000 --- a/test/hex/once_cache_test.exs +++ /dev/null @@ -1,323 +0,0 @@ -defmodule Hex.OnceCacheTest do - use ExUnit.Case, async: true - - setup do - cache_name = :"cache_#{:erlang.unique_integer([:positive])}" - {:ok, _pid} = Hex.OnceCache.start_link(name: cache_name) - %{cache: cache_name} - end - - describe "fetch/2" do - test "computes value on first call", %{cache: cache} do - pid = self() - ref = make_ref() - - result = - Hex.OnceCache.fetch(cache, fn -> - send(pid, {:computed, ref}) - :computed_value - end) - - assert result == :computed_value - assert_received {:computed, ^ref} - end - - test "returns cached value on subsequent calls", %{cache: cache} do - pid = self() - - compute_fn = fn -> - send(pid, :computed) - :cached_value - end - - # First call computes - assert Hex.OnceCache.fetch(cache, compute_fn) == :cached_value - assert_received :computed - - # Second call uses cached value - assert Hex.OnceCache.fetch(cache, compute_fn) == :cached_value - refute_received :computed - - # Third call still uses cached value - assert Hex.OnceCache.fetch(cache, compute_fn) == :cached_value - refute_received :computed - end - - test "handles concurrent calls correctly", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - # Simulate slow computation - Process.sleep(50) - :result - end - - # Spawn 10 concurrent tasks - tasks = - for _ <- 1..10 do - Task.async(fn -> - Hex.OnceCache.fetch(cache, compute_fn) - end) - end - - # All should return the same result - results = Task.await_many(tasks) - assert Enum.all?(results, &(&1 == :result)) - - # Compute function should only be called once - assert :counters.get(counter, 1) == 1 - end - - test "caches nil values", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - nil - end - - assert Hex.OnceCache.fetch(cache, compute_fn) == nil - assert :counters.get(counter, 1) == 1 - - # Should use cached nil, not recompute - assert Hex.OnceCache.fetch(cache, compute_fn) == nil - assert :counters.get(counter, 1) == 1 - end - - test "caches false values", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - false - end - - refute Hex.OnceCache.fetch(cache, compute_fn) - assert :counters.get(counter, 1) == 1 - - # Should use cached false, not recompute - refute Hex.OnceCache.fetch(cache, compute_fn) - assert :counters.get(counter, 1) == 1 - end - end - - describe "put/2" do - test "stores a value without computing", %{cache: cache} do - Hex.OnceCache.put(cache, :put_value) - - # fetch should return the put value without calling compute function - result = - Hex.OnceCache.fetch(cache, fn -> - flunk("Compute function should not be called") - end) - - assert result == :put_value - end - - test "overwrites previously computed value", %{cache: cache} do - # First compute a value - Hex.OnceCache.fetch(cache, fn -> :computed_value end) - - # Then put a different value - Hex.OnceCache.put(cache, :new_value) - - # fetch should return the new value - result = Hex.OnceCache.fetch(cache, fn -> :should_not_be_called end) - assert result == :new_value - end - end - - describe "clear/1" do - test "resets cache to not_fetched state", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - :computed_value - end - - # Compute and cache a value - assert Hex.OnceCache.fetch(cache, compute_fn) == :computed_value - assert :counters.get(counter, 1) == 1 - - # Clear the cache - Hex.OnceCache.clear(cache) - - # Next fetch should recompute - assert Hex.OnceCache.fetch(cache, compute_fn) == :computed_value - assert :counters.get(counter, 1) == 2 - end - - test "clears a put value", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - :computed_value - end - - # Put a value - Hex.OnceCache.put(cache, :put_value) - - # Clear it - Hex.OnceCache.clear(cache) - - # Next fetch should compute - assert Hex.OnceCache.fetch(cache, compute_fn) == :computed_value - assert :counters.get(counter, 1) == 1 - end - end - - describe "fetch_key/4" do - test "computes value on first call for a key", %{cache: cache} do - result = - Hex.OnceCache.fetch_key(cache, :key1, fn -> - :value1 - end) - - assert result == :value1 - end - - test "returns cached value on subsequent calls for same key", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - :value - end - - assert Hex.OnceCache.fetch_key(cache, :key1, compute_fn) == :value - assert :counters.get(counter, 1) == 1 - - assert Hex.OnceCache.fetch_key(cache, :key1, compute_fn) == :value - assert :counters.get(counter, 1) == 1 - end - - test "computes independently for different keys", %{cache: cache} do - assert Hex.OnceCache.fetch_key(cache, :key1, fn -> :value1 end) == :value1 - assert Hex.OnceCache.fetch_key(cache, :key2, fn -> :value2 end) == :value2 - - # Both are cached independently - assert Hex.OnceCache.fetch_key(cache, :key1, fn -> :should_not_compute end) == :value1 - assert Hex.OnceCache.fetch_key(cache, :key2, fn -> :should_not_compute end) == :value2 - end - - test "handles concurrent calls for the same key", %{cache: cache} do - counter = :counters.new(1, []) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - Process.sleep(50) - :result - end - - tasks = - for _ <- 1..10 do - Task.async(fn -> - Hex.OnceCache.fetch_key(cache, :key1, compute_fn) - end) - end - - results = Task.await_many(tasks) - assert Enum.all?(results, &(&1 == :result)) - assert :counters.get(counter, 1) == 1 - end - - test "computes different keys concurrently", %{cache: cache} do - # Both keys start computing at the same time. - # If serialized, total time would be >= 200ms. - # If concurrent, total time should be ~100ms. - compute_fn = fn -> - Process.sleep(100) - :result - end - - task1 = Task.async(fn -> Hex.OnceCache.fetch_key(cache, :key1, compute_fn) end) - task2 = Task.async(fn -> Hex.OnceCache.fetch_key(cache, :key2, compute_fn) end) - - {elapsed, results} = :timer.tc(fn -> Task.await_many([task1, task2]) end) - - assert results == [:result, :result] - # Should complete in roughly 100ms, not 200ms - assert elapsed < 180_000 - end - - @tag :capture_log - test "hands off to next waiter when computing process crashes", %{cache: cache} do - caller = self() - - spawn(fn -> - Hex.OnceCache.fetch_key(cache, :key1, fn -> - send(caller, :started) - Process.sleep(100) - raise "crash" - end) - end) - - assert_receive :started - - task2 = - Task.async(fn -> - Hex.OnceCache.fetch_key(cache, :key1, fn -> :recovered end) - end) - - assert Task.await(task2, 5000) == :recovered - end - - test "clear resets all keys", %{cache: cache} do - counter = :counters.new(1, []) - - Hex.OnceCache.fetch_key(cache, :key1, fn -> :value1 end) - Hex.OnceCache.fetch_key(cache, :key2, fn -> :value2 end) - - Hex.OnceCache.clear(cache) - - compute_fn = fn -> - :counters.add(counter, 1, 1) - :recomputed - end - - assert Hex.OnceCache.fetch_key(cache, :key1, compute_fn) == :recomputed - assert :counters.get(counter, 1) == 1 - end - end - - describe "fetch/3 with timeout" do - test "respects custom timeout for long operations", %{cache: cache} do - compute_fn = fn -> - Process.sleep(100) - :long_operation - end - - result = Hex.OnceCache.fetch(cache, compute_fn, timeout: 200) - assert result == :long_operation - end - - test "waiter times out if computation exceeds timeout", %{cache: cache} do - # Start a slow computation in another process - Task.async(fn -> - Hex.OnceCache.fetch(cache, fn -> - Process.sleep(200) - :slow_result - end) - end) - - # Give the task time to start computing - Process.sleep(10) - - # A waiter with a short timeout should time out - assert catch_exit(Hex.OnceCache.fetch(cache, fn -> :unused end, timeout: 50)) - end - - test "accepts :infinity timeout", %{cache: cache} do - compute_fn = fn -> - Process.sleep(100) - :result - end - - result = Hex.OnceCache.fetch(cache, compute_fn, timeout: :infinity) - assert result == :result - end - end -end diff --git a/test/hex/remote_converger_test.exs b/test/hex/remote_converger_test.exs index c723097e..366c1979 100644 --- a/test/hex/remote_converger_test.exs +++ b/test/hex/remote_converger_test.exs @@ -96,12 +96,10 @@ defmodule Hex.RemoteConvergerTest do end defp store_expired_oauth_token do - Hex.OnceCache.clear(Hex.OAuth.RefreshCache) - Hex.OAuth.store_token(%{ - "access_token" => "expired_access_token", - "refresh_token" => "invalid_refresh_token", - "expires_at" => System.system_time(:second) - 3600 + access_token: "expired_access_token", + refresh_token: "invalid_refresh_token", + expires_at: System.system_time(:second) - 3600 }) end @@ -138,47 +136,6 @@ defmodule Hex.RemoteConvergerTest do end) end - test "auth preflight is skipped for public repos" do - assert Hex.RemoteConverger.auth_preflight_required?([{"hexpm", "postgrex"}]) == false - end - - test "auth preflight is required for organization repos without repo auth" do - in_tmp(fn -> - set_home_cwd() - - assert Hex.RemoteConverger.auth_preflight_required?([ - {"hexpm:remote_converger_org", "private_prompt_pkg"} - ]) - end) - end - - test "auth preflight is skipped when repo auth is already available" do - in_tmp(fn -> - set_home_cwd() - - auth = new_repo_auth_user("remote_converger_repo_auth_preflight") - - repos = Hex.State.fetch!(:repos) - repos = put_in(repos["hexpm"].auth_key, auth[:key]) - Hex.State.put(:repos, repos) - - assert Hex.RemoteConverger.auth_preflight_required?([ - {"hexpm:remote_converger_org", "private_prompt_pkg"} - ]) == false - end) - end - - test "auth preflight is skipped for untrusted org repos" do - in_tmp(fn -> - set_home_cwd() - Hex.State.put(:mirror_url, "http://example.com") - - assert Hex.RemoteConverger.auth_preflight_required?([ - {"hexpm:remote_converger_org", "private_prompt_pkg"} - ]) == false - end) - end - test "deps.get does not prompt when repo auth is already available" do in_tmp(fn -> set_home_cwd() diff --git a/test/hex/repo_test.exs b/test/hex/repo_test.exs index ebaa8e3d..772023c0 100644 --- a/test/hex/repo_test.exs +++ b/test/hex/repo_test.exs @@ -61,9 +61,9 @@ defmodule Hex.RepoTest do hexpm = Hex.Repo.default_hexpm_repo() Hex.OAuth.store_token(%{ - "access_token" => "device_flow_token", - "refresh_token" => "device_refresh", - "expires_at" => System.system_time(:second) + 3600 + access_token: "device_flow_token", + refresh_token: "device_refresh", + expires_at: System.system_time(:second) + 3600 }) Hex.State.put(:mirror_url, "http://localhost:#{bypass.port}") @@ -240,7 +240,7 @@ defmodule Hex.RepoTest do oauth_exchange: true, public_key: _, trusted: true, - url: "http://localhost:4043/repo/repos/acme" + url: "http://localhost:4043/repo" }} = Hex.Repo.fetch_repo("hexpm:acme") Hex.State.put(:trusted_mirror_url, "http://example.com") @@ -260,7 +260,7 @@ defmodule Hex.RepoTest do oauth_exchange: true, public_key: _, trusted: true, - url: "http://example.com/repos/acme" + url: "http://example.com" }} = Hex.Repo.fetch_repo("hexpm:acme") Hex.State.put(:trusted_mirror_url, nil) @@ -280,7 +280,7 @@ defmodule Hex.RepoTest do oauth_exchange: true, public_key: _, trusted: false, - url: "http://example.com/repos/acme" + url: "http://example.com" }} = Hex.Repo.fetch_repo("hexpm:acme") end @@ -305,13 +305,54 @@ defmodule Hex.RepoTest do oauth_exchange: true, public_key: "public", trusted: true, - url: "http://example.com/repos/acme" + url: "http://example.com" } } = Hex.Repo.update_organizations(repos) after {:ok, _} = Supervisor.start_child(Hex.Supervisor, Hex.State) end + test "update_organizations/1 normalizes a legacy baked-in org URL" do + repos = Hex.State.fetch!(:repos) + source_url = repos["hexpm"].url + repos = Map.put(repos, "hexpm:acme", %{url: source_url <> "/repos/acme"}) + + updated = Hex.Repo.update_organizations(repos) + + # build_url appends /repos/acme, so the stored URL must be the bare source URL + assert updated["hexpm:acme"].url == source_url + assert updated["hexpm:acme"].repo_organization == "acme" + end + + test "update_organizations/1 keeps a custom org URL without repo_organization" do + repos = Hex.State.fetch!(:repos) + repos = Map.put(repos, "hexpm:acme", %{url: "http://custom.example/mirror"}) + + updated = Hex.Repo.update_organizations(repos) + + assert updated["hexpm:acme"].url == "http://custom.example/mirror" + refute Map.has_key?(updated["hexpm:acme"], :repo_organization) + end + + test "clean_organizations/1 round-trips derived and custom org URLs" do + repos = Hex.State.fetch!(:repos) + source_url = repos["hexpm"].url + + repos = + repos + |> Map.put("hexpm:derived", %{url: source_url <> "/repos/derived"}) + |> Map.put("hexpm:custom", %{url: "http://custom.example/mirror"}) + + cleaned = repos |> Hex.Repo.update_organizations() |> Hex.Repo.clean_organizations() + + # Derived URL is rebuilt on read, so it is not persisted + refute Map.has_key?(cleaned["hexpm:derived"], :url) + refute Map.has_key?(cleaned["hexpm:derived"], :repo_organization) + + # Custom URL is persisted verbatim + assert cleaned["hexpm:custom"].url == "http://custom.example/mirror" + end + describe "get_package/3" do test "from public repo" do assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "ex_doc", "") @@ -340,7 +381,7 @@ defmodule Hex.RepoTest do repos_after = Hex.State.fetch!(:repos) token_data = repos_after["hexpm:testorg"].oauth_token - assert is_binary(token_data["access_token"]) + assert is_binary(token_data[:access_token]) end test "organization repo skips oauth_exchange when disabled on parent" do @@ -398,14 +439,14 @@ defmodule Hex.RepoTest do repos_after = Hex.State.fetch!(:repos) token_data = repos_after["hexpm"].oauth_token assert is_map(token_data) - assert is_binary(token_data["access_token"]) - assert is_integer(token_data["expires_at"]) - first_token = token_data["access_token"] + assert is_binary(token_data[:access_token]) + assert is_integer(token_data[:expires_at]) + first_token = token_data[:access_token] assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "postgrex", "") repos_after_2 = Hex.State.fetch!(:repos) - reused_token = repos_after_2["hexpm"].oauth_token["access_token"] + reused_token = repos_after_2["hexpm"].oauth_token[:access_token] assert reused_token == first_token end @@ -414,8 +455,8 @@ defmodule Hex.RepoTest do api_key = auth[:key] expired_token_data = %{ - "access_token" => "expired_token", - "expires_at" => System.system_time(:second) - 100 + access_token: "expired_token", + expires_at: System.system_time(:second) - 100 } repos = Hex.State.fetch!(:repos) @@ -426,7 +467,7 @@ defmodule Hex.RepoTest do assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "postgrex", "") repos_after = Hex.State.fetch!(:repos) - new_token = repos_after["hexpm"].oauth_token["access_token"] + new_token = repos_after["hexpm"].oauth_token[:access_token] assert new_token != "expired_token" assert is_binary(new_token) end @@ -470,7 +511,7 @@ defmodule Hex.RepoTest do assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "postgrex", "") repos_after1 = Hex.State.fetch!(:repos) - token1 = repos_after1["hexpm"].oauth_token["access_token"] + token1 = repos_after1["hexpm"].oauth_token[:access_token] repos = Hex.State.fetch!(:repos) repos = put_in(repos["hexpm"].auth_key, api_key2) @@ -480,7 +521,7 @@ defmodule Hex.RepoTest do assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "postgrex", "") repos_after2 = Hex.State.fetch!(:repos) - token2 = repos_after2["hexpm"].oauth_token["access_token"] + token2 = repos_after2["hexpm"].oauth_token[:access_token] assert token1 != token2 end @@ -500,8 +541,8 @@ defmodule Hex.RepoTest do api_key = auth[:key] almost_expired_token = %{ - "access_token" => "almost_expired", - "expires_at" => System.system_time(:second) + 30 + access_token: "almost_expired", + expires_at: System.system_time(:second) + 30 } repos = Hex.State.fetch!(:repos) @@ -512,7 +553,7 @@ defmodule Hex.RepoTest do assert {:ok, {200, _, _}} = Hex.Repo.get_package("hexpm", "postgrex", "") repos_after = Hex.State.fetch!(:repos) - new_token = repos_after["hexpm"].oauth_token["access_token"] + new_token = repos_after["hexpm"].oauth_token[:access_token] assert new_token != "almost_expired" end end @@ -522,9 +563,9 @@ defmodule Hex.RepoTest do future_time = System.system_time(:second) + 3600 token_data = %{ - "access_token" => "device_flow_token", - "refresh_token" => "device_refresh", - "expires_at" => future_time + access_token: "device_flow_token", + refresh_token: "device_refresh", + expires_at: future_time } Hex.OAuth.store_token(token_data) @@ -560,9 +601,9 @@ defmodule Hex.RepoTest do future_time = System.system_time(:second) + 3600 token_data = %{ - "access_token" => "device_flow_token", - "refresh_token" => "device_refresh", - "expires_at" => future_time + access_token: "device_flow_token", + refresh_token: "device_refresh", + expires_at: future_time } Hex.OAuth.store_token(token_data) @@ -575,16 +616,16 @@ defmodule Hex.RepoTest do repos_after = Hex.State.fetch!(:repos) assert repos_after["hexpm"].oauth_token != nil - assert repos_after["hexpm"].oauth_token["access_token"] != "device_flow_token" + assert repos_after["hexpm"].oauth_token[:access_token] != "device_flow_token" end test "raises error when configured API key exchange fails (no fallback to device flow)" do future_time = System.system_time(:second) + 3600 token_data = %{ - "access_token" => "device_flow_token", - "refresh_token" => "device_refresh", - "expires_at" => future_time + access_token: "device_flow_token", + refresh_token: "device_refresh", + expires_at: future_time } Hex.OAuth.store_token(token_data) diff --git a/test/mix/tasks/hex.organization_test.exs b/test/mix/tasks/hex.organization_test.exs index eb6fba78..08a82b14 100644 --- a/test/mix/tasks/hex.organization_test.exs +++ b/test/mix/tasks/hex.organization_test.exs @@ -16,7 +16,7 @@ defmodule Mix.Tasks.Hex.OrganizationTest do hexpm = Hex.Repo.get_repo("hexpm") assert myorg.public_key == hexpm.public_key - assert myorg.url == "http://localhost:4043/repo/repos/myorgauth" + assert myorg.url == "http://localhost:4043/repo" assert is_binary(myorg.auth_key) {:ok, hostname} = :inet.gethostname() @@ -71,7 +71,7 @@ defmodule Mix.Tasks.Hex.OrganizationTest do hexpm = Hex.Repo.get_repo("hexpm") assert myorg.public_key == hexpm.public_key - assert myorg.url == "http://localhost:4043/repo/repos/myorgauthwithkeyname" + assert myorg.url == "http://localhost:4043/repo" assert is_binary(myorg.auth_key) assert {:ok, {200, _, body}} = Hex.API.Key.get(auth) @@ -94,14 +94,14 @@ defmodule Mix.Tasks.Hex.OrganizationTest do hexpm = Hex.Repo.get_repo("hexpm") assert myorg.public_key == hexpm.public_key - assert myorg.url == "http://localhost:4043/repo/repos/myorgauthkey" + assert myorg.url == "http://localhost:4043/repo" assert myorg.auth_key == body["secret"] repos = Hex.Config.read_repos(Hex.Config.read()) assert repo = repos["hexpm:myorgauthkey"] assert repo[:auth_key] assert repo[:trusted] - assert repo[:url] == "http://localhost:4043/repo/repos/myorgauthkey" + assert repo[:url] == "http://localhost:4043/repo" refute Map.has_key?(Hex.Config.read()[:"$repos"]["hexpm:myorgauthkey"], :trusted) end) diff --git a/test/mix/tasks/hex.user_test.exs b/test/mix/tasks/hex.user_test.exs index 65d18f2c..9fb36bce 100644 --- a/test/mix/tasks/hex.user_test.exs +++ b/test/mix/tasks/hex.user_test.exs @@ -10,7 +10,7 @@ defmodule Mix.Tasks.Hex.UserTest do Hex.OAuth.clear_tokens() # Test that device authorization works but don't try to complete the flow - assert {:ok, {200, _headers, response}} = Hex.API.OAuth.device_authorization("api") + assert {:ok, {200, _headers, response}} = device_authorization("api") assert %{ "device_code" => device_code, @@ -24,7 +24,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Test that polling returns authorization_pending (user hasn't authorized yet) assert {:ok, {400, _headers, %{"error" => "authorization_pending"}}} = - Hex.API.OAuth.poll_device_token(device_code) + poll_device_token(device_code) # Verify no tokens were stored since flow didn't complete refute Hex.OAuth.has_tokens?() @@ -39,7 +39,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Test device authorization assert {:ok, {200, _headers, %{"device_code" => device_code}}} = - Hex.API.OAuth.device_authorization("api") + device_authorization("api") assert is_binary(device_code) end) @@ -55,7 +55,7 @@ defmodule Mix.Tasks.Hex.UserTest do custom_name = "MyTestDevice" assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api repositories", custom_name) + device_authorization("api repositories", custom_name) assert %{ "device_code" => device_code, @@ -67,7 +67,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Verify the flow works with the name parameter assert {:ok, {400, _headers, %{"error" => "authorization_pending"}}} = - Hex.API.OAuth.poll_device_token(device_code) + poll_device_token(device_code) end) end @@ -79,7 +79,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Test device authorization with nil name (should work) assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api repositories", nil) + device_authorization("api repositories", nil) assert %{"device_code" => device_code} = response assert is_binary(device_code) @@ -94,7 +94,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Test polling with an invalid device code assert {:ok, {400, _headers, %{"error" => error}}} = - Hex.API.OAuth.poll_device_token("invalid_device_code") + poll_device_token("invalid_device_code") assert error in ["authorization_pending", "invalid_grant", "expired_token"] end) @@ -108,11 +108,11 @@ defmodule Mix.Tasks.Hex.UserTest do # Test that repeated polling gets proper response assert {:ok, {200, _headers, %{"device_code" => device_code}}} = - Hex.API.OAuth.device_authorization("api") + device_authorization("api") # Immediate polling should get authorization_pending assert {:ok, {400, _headers, %{"error" => "authorization_pending"}}} = - Hex.API.OAuth.poll_device_token(device_code) + poll_device_token(device_code) end) end @@ -123,7 +123,7 @@ defmodule Mix.Tasks.Hex.UserTest do Hex.OAuth.clear_tokens() # Test device authorization returns proper structure - assert {:ok, {200, _headers, response}} = Hex.API.OAuth.device_authorization("api") + assert {:ok, {200, _headers, response}} = device_authorization("api") assert %{ "device_code" => _, @@ -143,11 +143,11 @@ defmodule Mix.Tasks.Hex.UserTest do auth = Hexpm.new_oauth_user("refreshuser", "refreshuser@mail.com", "password") # Extract refresh token from auth - refresh_token = auth[:refresh_token] + token = auth[:refresh_token] # Test token refresh assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.refresh_token(refresh_token) + refresh_token(token) assert %{ "access_token" => new_access_token, @@ -180,205 +180,6 @@ defmodule Mix.Tasks.Hex.UserTest do end) end - test "inline authentication when no auth present" do - in_tmp(fn -> - set_home_cwd() - - # Clear all auth - Hex.OAuth.clear_tokens() - - # User says no to authenticate inline (to avoid hanging on real OAuth flow) - send(self(), {:mix_shell_input, :yes?, false}) - - # Calling auth_info should ask for inline auth - assert_raise Mix.Error, "No authenticated user found. Run `mix hex.user auth`", fn -> - Mix.Tasks.Hex.auth_info(:write) - end - - assert_received {:mix_shell, :yes?, - ["No authenticated user found. Do you want to authenticate now?"]} - end) - end - - test "inline authentication declined by user" do - in_tmp(fn -> - set_home_cwd() - - # Clear all auth - Hex.OAuth.clear_tokens() - - # User says no to authenticate inline - send(self(), {:mix_shell_input, :yes?, false}) - - # Should raise when user declines - assert_raise Mix.Error, "No authenticated user found. Run `mix hex.user auth`", fn -> - Mix.Tasks.Hex.auth_info(:write) - end - - assert_received {:mix_shell, :yes?, - ["No authenticated user found. Do you want to authenticate now?"]} - end) - end - - test "inline authentication accepted by user" do - in_tmp(fn -> - set_home_cwd() - - bypass = Bypass.open() - original_url = Hex.State.fetch!(:api_url) - Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") - - # Clear all auth - Hex.OAuth.clear_tokens() - - # User says yes to authenticate inline - send(self(), {:mix_shell_input, :yes?, true}) - - # Mock the OAuth flow for inline auth - Bypass.expect(bypass, "POST", "/api/oauth/device_authorization", fn conn -> - conn - |> Plug.Conn.put_resp_header("content-type", "application/vnd.hex+erlang") - |> Plug.Conn.resp( - 200, - Hex.Utils.safe_serialize_erlang(%{ - "device_code" => "inline_device", - "user_code" => "INLINE", - "verification_uri" => "https://hex.pm/oauth/device", - "expires_in" => 600, - "interval" => 0 - }) - ) - end) - - # Mock polling - succeed immediately - Bypass.expect(bypass, "POST", "/api/oauth/token", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - params = Hex.Utils.safe_deserialize_erlang(body) - - resp_body = - case params["grant_type"] do - "urn:ietf:params:oauth:grant-type:device_code" -> - %{ - "access_token" => "inline_token", - "token_type" => "bearer", - "expires_in" => 3600, - "refresh_token" => "inline_refresh", - "scope" => "api repositories" - } - end - - conn - |> Plug.Conn.put_resp_header("content-type", "application/vnd.hex+erlang") - |> Plug.Conn.resp(200, Hex.Utils.safe_serialize_erlang(resp_body)) - end) - - # Calling auth_info should trigger inline auth - auth = Mix.Tasks.Hex.auth_info(:write) - - # Should get auth after inline flow with OAuth flag - assert [key: _token, oauth: true] = auth - - assert_received {:mix_shell, :yes?, - ["No authenticated user found. Do you want to authenticate now?"]} - - Hex.State.put(:api_url, original_url) - end) - end - - test "auth_info fallback behavior" do - in_tmp(fn -> - set_home_cwd() - - # Test fallback from OAuth to API keys - Hex.OAuth.clear_tokens() - - # No auth should trigger inline auth (but we disable it) - assert [] = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - - # Test with API key set - Hex.State.put(:api_key, "test_api_key") - assert [key: "test_api_key"] = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - - # Test with OAuth tokens - future_time = System.system_time(:second) + 3600 - - tokens = %{ - "access_token" => "oauth_token", - "refresh_token" => "oauth_refresh", - "expires_at" => future_time - } - - Hex.OAuth.store_token(tokens) - - assert [key: "oauth_token", oauth: true] = - Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - - # Clear OAuth tokens - should fall back to API key - Hex.OAuth.clear_tokens() - assert [key: "test_api_key"] = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - end) - end - - test "auth_info with expired tokens triggers refresh" do - in_tmp(fn -> - set_home_cwd() - - bypass = Bypass.open() - original_url = Hex.State.fetch!(:api_url) - Hex.State.put(:api_url, "http://localhost:#{bypass.port}/api") - - # Store expired OAuth tokens - past_time = System.system_time(:second) - 3600 - - tokens = %{ - "access_token" => "expired_token", - "refresh_token" => "refresh_token", - "expires_at" => past_time - } - - Hex.OAuth.store_token(tokens) - - # Mock refresh token endpoint - Bypass.expect(bypass, "POST", "/api/oauth/token", fn conn -> - {:ok, body, conn} = Plug.Conn.read_body(conn) - - # Check which refresh token is being used - cond do - String.contains?(body, "refresh_token") -> - conn - |> Plug.Conn.put_resp_header("content-type", "application/vnd.hex+erlang") - |> Plug.Conn.resp( - 200, - Hex.Utils.safe_serialize_erlang(%{ - "access_token" => "new_token", - "token_type" => "bearer", - "expires_in" => 3600, - "refresh_token" => "new_refresh_token", - "scope" => "api:write" - }) - ) - - true -> - conn - |> Plug.Conn.resp(400, "Bad request") - end - end) - - # Call auth_info - should trigger refresh - auth = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - - # Should get new token after refresh - assert [key: "new_token", oauth: true] = auth - - # Verify new tokens were stored - config = Hex.Config.read() - assert config[:"$oauth_token"]["access_token"] == "new_token" - assert config[:"$oauth_token"]["refresh_token"] == "new_refresh_token" - - Hex.State.put(:api_url, original_url) - end) - end - test "deauth user and organizations" do in_tmp(fn -> set_home_cwd() @@ -389,9 +190,9 @@ defmodule Mix.Tasks.Hex.UserTest do # Store OAuth tokens tokens = %{ - "access_token" => auth[:access_token], - "refresh_token" => auth[:refresh_token], - "expires_at" => System.system_time(:second) + 3600 + access_token: auth[:access_token], + refresh_token: auth[:refresh_token], + expires_at: System.system_time(:second) + 3600 } Hex.OAuth.store_token(tokens) @@ -417,7 +218,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Try to refresh with invalid refresh token assert {:ok, {400, _headers, %{"error" => _}}} = - Hex.API.OAuth.refresh_token("invalid_refresh_token") + refresh_token("invalid_refresh_token") end) end @@ -431,9 +232,9 @@ defmodule Mix.Tasks.Hex.UserTest do # Store tokens tokens = %{ - "access_token" => "write_access", - "refresh_token" => "write_refresh", - "expires_at" => System.system_time(:second) + 3600 + access_token: "write_access", + refresh_token: "write_refresh", + expires_at: System.system_time(:second) + 3600 } Hex.OAuth.store_token(tokens) @@ -459,9 +260,9 @@ defmodule Mix.Tasks.Hex.UserTest do # Store OAuth token tokens = %{ - "access_token" => auth[:access_token], - "refresh_token" => auth[:refresh_token], - "expires_at" => System.system_time(:second) + 3600 + access_token: auth[:access_token], + refresh_token: auth[:refresh_token], + expires_at: System.system_time(:second) + 3600 } Hex.OAuth.store_token(tokens) @@ -490,7 +291,7 @@ defmodule Mix.Tasks.Hex.UserTest do # Test device authorization with custom scopes assert {:ok, {200, _headers, response}} = - Hex.API.OAuth.device_authorization("api:write repositories") + device_authorization("api:write repositories") assert %{ "device_code" => device_code, @@ -502,57 +303,34 @@ defmodule Mix.Tasks.Hex.UserTest do # Polling should return pending since user hasn't authorized assert {:ok, {400, _headers, %{"error" => "authorization_pending"}}} = - Hex.API.OAuth.poll_device_token(device_code) + poll_device_token(device_code) end) end - test "auth_info includes OTP from HEX_OTP environment variable" do - in_tmp(fn -> - set_home_cwd() - - # Setup OAuth tokens - future_time = System.system_time(:second) + 3600 + defp device_authorization(scopes, name \\ nil) do + opts = if name, do: [name: name], else: [] - tokens = %{ - "access_token" => "oauth_token", - "refresh_token" => "refresh_token", - "expires_at" => future_time - } - - Hex.OAuth.store_token(tokens) - - # Set HEX_OTP in state - Hex.State.put(:api_otp, "123456") - - # Get auth info - should include OTP - auth = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) - - assert [key: "oauth_token", oauth: true, otp: "123456"] = auth - end) + :mix_hex_api_oauth.device_authorization( + Hex.API.Client.config(), + Hex.API.OAuth.client_id(), + scopes, + opts + ) end - test "auth_info does not prompt for OTP when HEX_OTP is not set" do - in_tmp(fn -> - set_home_cwd() - - # Setup OAuth tokens - future_time = System.system_time(:second) + 3600 - - tokens = %{ - "access_token" => "oauth_token", - "refresh_token" => "refresh_token", - "expires_at" => future_time - } - - Hex.OAuth.store_token(tokens) - - # Don't set HEX_OTP - should not prompt upfront - Hex.State.put(:api_otp, nil) - - # Get auth info - should not include OTP (server will prompt if needed) - auth = Mix.Tasks.Hex.auth_info(:write, auth_inline: false) + defp poll_device_token(device_code) do + :mix_hex_api_oauth.poll_device_token( + Hex.API.Client.config(), + Hex.API.OAuth.client_id(), + device_code + ) + end - assert [key: "oauth_token", oauth: true] = auth - end) + defp refresh_token(token) do + :mix_hex_api_oauth.refresh_token( + Hex.API.Client.config(), + Hex.API.OAuth.client_id(), + token + ) end end diff --git a/test/setup_hexpm.exs b/test/setup_hexpm.exs index 43067665..a9a0a6ee 100644 --- a/test/setup_hexpm.exs +++ b/test/setup_hexpm.exs @@ -7,7 +7,7 @@ Hexpm.start() config = Hex.API.Client.config() body = %{ - "client_id" => "78ea6566-89fd-481e-a1d6-7d9d78eacca8", + "client_id" => Hex.API.OAuth.client_id(), "client_type" => "public", "name" => "Hex CLI" } diff --git a/test/support/case.ex b/test/support/case.ex index 1988fb51..3cb437d7 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -306,7 +306,6 @@ defmodule HexTest.Case do def reset_state do Hex.State.put_all(Application.get_env(:hex, :reset_state)) Hex.OAuth.clear_tokens() - Hex.Repo.clear_exchange_cache() end def set_home_cwd() do diff --git a/test/support/hexpm.ex b/test/support/hexpm.ex index 50cd6950..c50a525c 100644 --- a/test/support/hexpm.ex +++ b/test/support/hexpm.ex @@ -319,9 +319,9 @@ defmodule HexTest.Hexpm do expires_at = System.system_time(:second) + token_response["expires_in"] token_data = %{ - "access_token" => token_response["access_token"], - "refresh_token" => token_response["refresh_token"], - "expires_at" => expires_at + access_token: token_response["access_token"], + refresh_token: token_response["refresh_token"], + expires_at: expires_at } # Store OAuth token @@ -349,9 +349,9 @@ defmodule HexTest.Hexpm do expires_at = System.system_time(:second) + 3600 token_data = %{ - "access_token" => access_token, - "refresh_token" => refresh_token, - "expires_at" => expires_at + access_token: access_token, + refresh_token: refresh_token, + expires_at: expires_at } # Store OAuth token @@ -377,9 +377,9 @@ defmodule HexTest.Hexpm do expires_at = System.system_time(:second) + 3600 token_data = %{ - "access_token" => access_token, - "refresh_token" => refresh_token, - "expires_at" => expires_at + access_token: access_token, + refresh_token: refresh_token, + expires_at: expires_at } Hex.OAuth.store_token(token_data) @@ -397,9 +397,9 @@ defmodule HexTest.Hexpm do expires_at = System.system_time(:second) - 100 token_data = %{ - "access_token" => access_token, - "refresh_token" => refresh_token, - "expires_at" => expires_at + access_token: access_token, + refresh_token: refresh_token, + expires_at: expires_at } Hex.OAuth.store_token(token_data)