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..5acc231b 100644 --- a/lib/hex/api/client.ex +++ b/lib/hex/api/client.ex @@ -26,9 +26,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 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..ebff2873 100644 --- a/lib/hex/api/oauth.ex +++ b/lib/hex/api/oauth.ex @@ -66,36 +66,22 @@ defmodule Hex.API.OAuth do end @doc """ - Exchanges an API key for a short-lived OAuth access token using the client credentials grant. + Runs the complete OAuth device authorization flow. - 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 - - 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.device_auth_flow(config, @client_id, scopes, prompt_user, opts) end @doc """ 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..23a6d081 --- /dev/null +++ b/lib/hex/auth.ex @@ -0,0 +1,143 @@ +defmodule Hex.Auth do + @moduledoc false + + @client_id "78ea6566-89fd-481e-a1d6-7d9d78eacca8" + + @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(callbacks(), 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(callbacks(), 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 + callbacks = Map.put(callbacks(), :get_auth_config, fn _ -> auth end) + :mix_hex_cli_auth.with_repo(callbacks, config, fun, opts) + end + + defp 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: &get_client_id/0, + should_authenticate: &should_authenticate/1 + } + end + + defp get_auth_config(repo) do + case Hex.State.get(:api_key) do + nil -> + case Hex.Repo.fetch_repo(repo) do + {:ok, config} -> config + :error -> :undefined + end + + 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 get_client_id do + @client_id + 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.info("Token refresh failed. Do you want to renew your authentication?") + end +end diff --git a/lib/hex/oauth.ex b/lib/hex/oauth.ex index 56222f39..26d399de 100644 --- a/lib/hex/oauth.ex +++ b/lib/hex/oauth.ex @@ -1,91 +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 - {: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 - {:error, :no_refresh_token} - end - end - 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 @@ -107,43 +32,18 @@ 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 + Hex.State.get(:oauth_token) != nil end @doc """ @@ -156,46 +56,4 @@ defmodule Hex.OAuth do |> 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 - end end diff --git a/lib/hex/remote_converger.ex b/lib/hex/remote_converger.ex index 96c3cb57..44e00f1b 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -48,10 +48,7 @@ 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) + check_and_refresh_auth() Registry.prefetch(prefetches) locked = prepare_locked(lock, old_lock, deps) @@ -750,62 +747,30 @@ defmodule Hex.RemoteConverger do version1.major == 0 and version2.major == 0 and version1.minor != version2.minor end - 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 - # detect the expired token concurrently - case Hex.OAuth.get_token(prompt_auth: true) do - {:ok, _access_token} -> - # Token is valid, was successfully refreshed, or user authenticated - :ok - - {:error, :auth_failed} -> - Hex.Shell.warn( - "Authentication failed. Private packages will not be available. " <> - "Run `mix hex.user auth` to authenticate." - ) - - {:error, :auth_declined} -> - Hex.Shell.warn( - "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 + defp check_and_refresh_auth do + config = Hex.API.Client.config([]) - {:error, _other} -> - # Do not fail dependency resolution during OAuth preflight; continue - # unauthenticated and let the repo fetch surface any auth error. - :ok - end - else - :ok - end - end - - @doc false - def auth_preflight_required?(prefetches) do - prefetches - |> Enum.map(fn {repo, _package} -> repo end) - |> Enum.uniq() - |> Enum.any?(&repo_requires_user_oauth?/1) - end + auth_result = + Hex.Auth.with_api( + :read, + config, + fn + %{api_key: api_key} when is_binary(api_key) -> :ok + %{} -> {:error, :no_auth} + end, + optional: true, + auth_inline: false + ) - defp repo_requires_user_oauth?("hexpm:" <> _ = repo) do - repo - |> Hex.Repo.get_repo() - |> repo_uses_user_oauth?() - end + case auth_result do + :ok -> + :ok - defp repo_requires_user_oauth?(_repo) do - false - end + {:error, :no_auth} -> + :ok - defp repo_uses_user_oauth?(repo_config) do - Map.get(repo_config, :trusted, true) && !repo_config.auth_key + {:error, reason} -> + Mix.raise("Failed to check authentication: #{inspect(reason)}") + end end end diff --git a/lib/hex/repo.ex b/lib/hex/repo.ex index 76326ae1..0b5db582 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,7 @@ defmodule Hex.Repo do end defp default_organization(repo, source, name) do - url = merge_values(Map.get(repo, :url), source.url <> "/repos/#{name}") + url = merge_values(Map.get(repo, :url), source.url) public_key = merge_values(Map.get(repo, :public_key), source.public_key) auth_key = merge_values(Map.get(repo, :auth_key), source.auth_key) @@ -73,6 +56,7 @@ defmodule Hex.Repo do repo |> Map.put(:url, url) + |> Map.put(:repo_organization, name) |> Map.put(:public_key, public_key) |> Map.put(:auth_key, auth_key) |> Map.put(:oauth_exchange, oauth_exchange) @@ -225,31 +209,38 @@ defmodule Hex.Repo do 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)) 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)) 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)) 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,52 +294,28 @@ 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, :undefined} + 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: organization, 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) } 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) -> - 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 @@ -357,82 +324,4 @@ defmodule Hex.Repo do config 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) - - if expires_at > current_time + 300 do - {:ok, token} - else - :expired - end - - _ -> - :not_found - 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 555eff13..ce5179d5 100644 --- a/lib/mix/tasks/hex.docs.ex +++ b/lib/mix/tasks/hex.docs.ex @@ -149,9 +149,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..554fb481 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) @@ -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 1d773074..3f9829ac 100644 --- a/lib/mix/tasks/hex.organization.ex +++ b/lib/mix/tasks/hex.organization.ex @@ -168,9 +168,8 @@ defmodule Mix.Tasks.Hex.Organization do else 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"] @@ -193,11 +192,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 @@ -208,11 +205,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 @@ -224,9 +219,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.publish.ex b/lib/mix/tasks/hex.publish.ex index 21a7c01c..0564a8ee 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, :no_credentials}} -> + 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.search.ex b/lib/mix/tasks/hex.search.ex index 642eb6bf..4257aba3 100644 --- a/lib/mix/tasks/hex.search.ex +++ b/lib/mix/tasks/hex.search.ex @@ -106,9 +106,8 @@ defmodule Mix.Tasks.Hex.Search do defp package_search(package, organization) do Hex.start() - auth = Mix.Tasks.Hex.auth_info(:read, auth_inline: false) - Hex.API.Package.search(organization, package, auth) + Hex.API.Package.search(organization, package) |> lookup_packages() end 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 af129430..76dd9d57 100755 --- a/scripts/vendor_hex_core.sh +++ b/scripts/vendor_hex_core.sh @@ -23,6 +23,7 @@ filenames="hex_api_auth.erl \ hex_api_short_url.erl \ hex_api_user.erl \ hex_api.erl \ + hex_cli_auth.erl \ hex_core.hrl \ hex_core.erl \ hex_erl_tar.erl \ @@ -56,6 +57,7 @@ search_to_replace="hex_core: \ hex_http \ hex_repo \ hex_api \ + hex_cli_auth \ safe_erl_term" rm -f $target_dir/$prefix* diff --git a/src/mix_hex_api.erl b/src/mix_hex_api.erl index 1bc95746..402b0118 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 8979fd38..b255b587 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 d35b6e88..b128c350 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 d8ac5bb6..4cac1e9a 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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,44 @@ 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. +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, + Port = open_port({spawn_executable, os:find_executable(Cmd)}, [{args, Args}]), + port_close(Port), + ok. + +%% @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 a9fdb034..7138288b 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 ab8fabe6..71f3b6c4 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.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 465f6717..227c9c96 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 b53f30d5..9d76594f 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.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 94c19765..a305552b 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 ef8904ec..b09ab44d 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.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 7ffa75e8..dc5de3a5 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.16.0 (0e332e5), do not edit manually +%% Vendored from hex_core v0.16.0 (c4c38cc), 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..da2ad083 --- /dev/null +++ b/src/mix_hex_cli_auth.erl @@ -0,0 +1,705 @@ +%% Vendored from hex_core v0.16.0 (c4c38cc), 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 == +%% +%% The caller provides a callbacks map with these functions (all 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: +%%