From 92ae2ac7d9ef0778aecaad38d1ad54bd872ebe1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20M=C3=A4nnchen?= Date: Sat, 11 Apr 2026 20:27:41 +0200 Subject: [PATCH 1/2] Adopt :hex_repo.fingerprint --- lib/hex/stdlib.ex | 16 ------ lib/mix/tasks/hex.repo.ex | 10 ++-- src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 2 +- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_core.erl | 2 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 44 ++++++++++++++--- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 2 +- src/mix_hex_repo.erl | 66 ++++++++++++++++++++++++- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- 29 files changed, 132 insertions(+), 54 deletions(-) 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.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/src/mix_hex_api.erl b/src/mix_hex_api.erl index d311fd4b..ceef0f2e 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Hex HTTP API diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index 80712229..498ccc72 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 3e31239a..67dc70a5 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 21949b58..66c8b9be 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Hex HTTP API - OAuth. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index 07433fc9..ce4a7b98 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 35c5b17f..fba04ab4 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.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 0351e0ff..149154de 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 eb52dafb..2241681c 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.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 effbd617..f9370373 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 6aab9672..d87f688a 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.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 dc5d3526..458d9a78 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Hex HTTP API - Users. diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index 18923c1c..ff2ef8da 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% `hex_core' entrypoint module. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index 8dbff979..a073d9af 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually -define(HEX_CORE_VERSION, "0.15.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index e3db3e8c..43966046 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 f25d9340..d4236023 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 0ef191f9..693bbc21 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index 62ac35de..78b76738 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% httpc-based implementation of {@link mix_hex_http} contract. @@ -41,15 +41,12 @@ request_to_file(Method, URI, ReqHeaders, Body, Filename, AdapterConfig) when is_ Method, Request, HTTPOptions, - [{stream, unicode:characters_to_list(Filename)}], + [{sync, false}, {stream, self}], Profile ) of - {ok, saved_to_file} -> - {ok, {200, #{}}}; - {ok, {{_, StatusCode, _}, RespHeaders, _RespBody}} -> - RespHeaders2 = load_headers(RespHeaders), - {ok, {StatusCode, RespHeaders2}}; + {ok, RequestId} -> + stream_to_file(RequestId, Filename); {error, Reason} -> {error, Reason} end. @@ -58,6 +55,39 @@ request_to_file(Method, URI, ReqHeaders, Body, Filename, AdapterConfig) when is_ %% Internal functions %%==================================================================== +%% @private +%% httpc streams 200/206 responses as messages and returns non-2xx as +%% a normal response tuple. stream_start includes the response headers. +stream_to_file(RequestId, Filename) -> + receive + {http, {RequestId, stream_start, Headers}} -> + {ok, File} = file:open(Filename, [write, binary]), + case stream_body(RequestId, File) of + ok -> + ok = file:close(File), + {ok, {200, load_headers(Headers)}}; + {error, Reason} -> + ok = file:close(File), + {error, Reason} + end; + {http, {RequestId, {{_, StatusCode, _}, RespHeaders, _RespBody}}} -> + {ok, {StatusCode, load_headers(RespHeaders)}}; + {http, {RequestId, {error, Reason}}} -> + {error, Reason} + end. + +%% @private +stream_body(RequestId, File) -> + receive + {http, {RequestId, stream, BinBodyPart}} -> + ok = file:write(File, BinBodyPart), + stream_body(RequestId, File); + {http, {RequestId, stream_end, _Headers}} -> + ok; + {http, {RequestId, {error, Reason}}} -> + {error, Reason} + end. + %% @private http_options(URI, AdapterConfig) -> HTTPOptions0 = maps:get(http_options, AdapterConfig, []), diff --git a/src/mix_hex_licenses.erl b/src/mix_hex_licenses.erl index 192f9091..d911c9fd 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index 75aa3055..f9101338 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 bb8e0574..a0432043 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 0a1ecefa..4afa5173 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 e9649979..9d228d71 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 b361e9ab..e02c4ea1 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index aadf5374..78fd81f0 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %% @doc %% Repo API. @@ -12,7 +12,9 @@ get_docs/3, get_docs_to_file/4, get_public_key/1, - get_hex_installs/1 + get_hex_installs/1, + fingerprint/1, + fingerprint_equal/2 ]). %%==================================================================== @@ -208,6 +210,66 @@ get_hex_installs(Config) -> Other end. +%% @doc +%% Computes a SHA256 fingerprint of a PEM-encoded public key. +%% +%% Returns a string in the format "SHA256:" which can be used +%% to verify public keys out-of-band. +%% +%% Examples: +%% +%% ``` +%% > mix_hex_repo:fingerprint(PublicKeyPem). +%% "SHA256:abc123..." +%% ''' +%% @end +-spec fingerprint(binary()) -> string(). +fingerprint(PublicKeyPem) when is_binary(PublicKeyPem) -> + [PemEntry] = public_key:pem_decode(PublicKeyPem), + PublicKey = public_key:pem_entry_decode(PemEntry), + application:ensure_all_started(ssh), + ssh:hostkey_fingerprint(sha256, PublicKey). + +%% @doc +%% Compares a PEM-encoded public key against an expected fingerprint. +%% +%% Uses constant-time comparison to prevent timing attacks. +%% +%% Examples: +%% +%% ``` +%% > mix_hex_repo:fingerprint_equal(PublicKeyPem, "SHA256:abc123..."). +%% true +%% ''' +%% @end +-spec fingerprint_equal(binary(), iodata()) -> boolean(). +fingerprint_equal(PublicKeyPem, ExpectedFingerprint) when is_binary(PublicKeyPem) -> + ActualFingerprint = fingerprint(PublicKeyPem), + constant_time_compare( + list_to_binary(ActualFingerprint), + iolist_to_binary(ExpectedFingerprint) + ). + +%% @private +%% Constant-time comparison to prevent timing attacks. +%% Uses crypto:hash_equals/2 on OTP 25+, falls back to manual comparison on older versions. +-if(?OTP_RELEASE >= 25). +constant_time_compare(A, B) when byte_size(A) =/= byte_size(B) -> + false; +constant_time_compare(A, B) -> + crypto:hash_equals(A, B). +-else. +constant_time_compare(A, B) when byte_size(A) =:= byte_size(B) -> + constant_time_compare(A, B, 0); +constant_time_compare(_, _) -> + false. + +constant_time_compare(<>, <>, Acc) -> + constant_time_compare(RestA, RestB, Acc bor (X bxor Y)); +constant_time_compare(<<>>, <<>>, Acc) -> + Acc =:= 0. +-endif. + %%==================================================================== %% Internal functions %%==================================================================== diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index 1e7e308f..5d9f46df 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.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 2379e0fb..692f9664 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), 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 cc6854b5..4f7e0b2c 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (90f9f59), do not edit manually +%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. From 54ec1bad1754d89a4fda9ae0f1467a944eec671f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20M=C3=A4nnchen?= Date: Tue, 14 Apr 2026 00:29:26 +0200 Subject: [PATCH 2/2] Refactor Code to use hex_cli_auth --- lib/hex/api/auth.ex | 7 +- lib/hex/api/client.ex | 5 +- lib/hex/api/key.ex | 84 ++- lib/hex/api/oauth.ex | 32 +- lib/hex/api/package.ex | 57 +- lib/hex/api/release.ex | 62 +- lib/hex/api/release_docs.ex | 57 +- lib/hex/api/user.ex | 15 +- lib/hex/application.ex | 4 - lib/hex/auth.ex | 143 +++++ lib/hex/oauth.ex | 160 +---- lib/hex/remote_converger.ex | 38 +- lib/hex/repo.ex | 161 +----- lib/mix/tasks/hex.docs.ex | 4 +- lib/mix/tasks/hex.ex | 281 +-------- lib/mix/tasks/hex.info.ex | 8 +- lib/mix/tasks/hex.organization.ex | 15 +- lib/mix/tasks/hex.owner.ex | 17 +- lib/mix/tasks/hex.publish.ex | 68 +-- lib/mix/tasks/hex.retire.ex | 7 +- lib/mix/tasks/hex.search.ex | 3 +- lib/mix/tasks/hex.user.ex | 4 +- scripts/vendor_hex_core.sh | 2 + src/mix_hex_api.erl | 2 +- src/mix_hex_api_auth.erl | 2 +- src/mix_hex_api_key.erl | 2 +- src/mix_hex_api_oauth.erl | 203 ++++++- src/mix_hex_api_organization.erl | 2 +- src/mix_hex_api_organization_member.erl | 2 +- src/mix_hex_api_package.erl | 2 +- src/mix_hex_api_package_owner.erl | 2 +- src/mix_hex_api_release.erl | 2 +- src/mix_hex_api_short_url.erl | 2 +- src/mix_hex_api_user.erl | 2 +- src/mix_hex_cli_auth.erl | 705 +++++++++++++++++++++++ src/mix_hex_core.erl | 12 +- src/mix_hex_core.hrl | 2 +- src/mix_hex_erl_tar.erl | 2 +- src/mix_hex_erl_tar.hrl | 2 +- src/mix_hex_http.erl | 2 +- src/mix_hex_http_httpc.erl | 2 +- src/mix_hex_licenses.erl | 2 +- src/mix_hex_pb_names.erl | 2 +- src/mix_hex_pb_package.erl | 2 +- src/mix_hex_pb_signed.erl | 2 +- src/mix_hex_pb_versions.erl | 2 +- src/mix_hex_registry.erl | 2 +- src/mix_hex_repo.erl | 2 +- src/mix_hex_safe_binary_to_term.erl | 2 +- src/mix_hex_tarball.erl | 2 +- src/mix_safe_erl_term.xrl | 2 +- test/hex/api/oauth_test.exs | 219 ------- test/hex/oauth_test.exs | 267 --------- test/hex/repo_test.exs | 34 +- test/mix/tasks/hex.organization_test.exs | 8 +- test/mix/tasks/hex.user_test.exs | 249 -------- test/support/case.ex | 1 - 57 files changed, 1402 insertions(+), 1580 deletions(-) create mode 100644 lib/hex/auth.ex create mode 100644 src/mix_hex_cli_auth.erl delete mode 100644 test/hex/api/oauth_test.exs delete mode 100644 test/hex/oauth_test.exs 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 76314fab..67536c04 100644 --- a/lib/hex/remote_converger.ex +++ b/lib/hex/remote_converger.ex @@ -735,33 +735,31 @@ defmodule Hex.RemoteConverger do end defp check_and_refresh_auth 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} -> + config = Hex.API.Client.config([]) + + 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 + ) + + case auth_result do + :ok -> # 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 OAuth token - this is OK, user might only be fetching public packages :ok - {:error, _other} -> - # Other errors (shouldn't happen with prompt_auth: true, but handle gracefully) - :ok + {: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 6de27afa..cb71d476 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,46 +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) == 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) -> - 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 (from device flow or other sources) - 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 + case Map.fetch(repo_config, :oath_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 @@ -351,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/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.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 ceef0f2e..4f1e901a 100644 --- a/src/mix_hex_api.erl +++ b/src/mix_hex_api.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% Hex HTTP API diff --git a/src/mix_hex_api_auth.erl b/src/mix_hex_api_auth.erl index 498ccc72..01e63782 100644 --- a/src/mix_hex_api_auth.erl +++ b/src/mix_hex_api_auth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 67dc70a5..770ce676 100644 --- a/src/mix_hex_api_key.erl +++ b/src/mix_hex_api_key.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 66c8b9be..4bad694c 100644 --- a/src/mix_hex_api_oauth.erl +++ b/src/mix_hex_api_oauth.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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,20 @@ 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()}. + %% @doc %% Initiates the OAuth device authorization flow. %% @@ -28,7 +44,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 +67,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 +341,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. +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() -> + case inet:gethostname() of + {ok, Hostname} -> + list_to_binary(Hostname); + {error, _} -> + <<"unknown">> + end. diff --git a/src/mix_hex_api_organization.erl b/src/mix_hex_api_organization.erl index ce4a7b98..4ea687dc 100644 --- a/src/mix_hex_api_organization.erl +++ b/src/mix_hex_api_organization.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 fba04ab4..6adae7c1 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.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 149154de..7f7320fc 100644 --- a/src/mix_hex_api_package.erl +++ b/src/mix_hex_api_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 2241681c..fdc19032 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.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 f9370373..83493690 100644 --- a/src/mix_hex_api_release.erl +++ b/src/mix_hex_api_release.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 d87f688a..9aabbdc0 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.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 458d9a78..78b85eab 100644 --- a/src/mix_hex_api_user.erl +++ b/src/mix_hex_api_user.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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..480cfd0c --- /dev/null +++ b/src/mix_hex_cli_auth.erl @@ -0,0 +1,705 @@ +%% Vendored from hex_core v0.15.0 (b8cea90), 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: +%%
    +%%
  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()': +%%
    +%%
  • `source' - Where the credentials came from (`env', `config', or `oauth')
  • +%%
  • `has_refresh_token' - Whether token refresh is possible on 401
  • +%%
+%% +%% == 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/4, + with_api/5, + with_repo/3, + with_repo/4, + resolve_api_auth/3, + resolve_repo_auth/2 +]). + +-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, 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(Callbacks, Permission, Config, Fun, [])'. +%% +%% @see with_api/5 +-spec with_api(callbacks(), permission(), mix_hex_core:config(), fun((mix_hex_core:config()) -> Result)) -> + Result | {error, auth_error()} +when + Result :: term(). +with_api(Callbacks, Permission, BaseConfig, Fun) -> + with_api(Callbacks, 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'). +%% +%% Options: +%%
    +%%
  • `optional' - When `true', if no credentials are found, executes the function +%% without authentication first. If the server returns 401, triggers auth +%% (respecting `auth_inline'). When `false' (default), missing credentials +%% immediately triggers the `should_authenticate' callback.
  • +%%
  • `auth_inline' - When `true' (default), prompts the user via `should_authenticate' +%% callback when authentication is needed. When `false', returns +%% `{error, {auth_error, no_credentials}}' instead of prompting.
  • +%%
  • `oauth_open_browser' - When `true' (default), automatically opens the browser +%% during device auth flow. When `false', only prints the URL for the user.
  • +%%
+%% +%% Example: +%% ``` +%% mix_hex_cli_auth:with_api(Callbacks, write, Config, fun(C) -> +%% mix_hex_api_release:publish(C, Tarball) +%% end, [{optional, false}, {auth_inline, true}]). +%% ''' +-spec with_api( + callbacks(), + permission(), + mix_hex_core:config(), + fun((mix_hex_core:config()) -> Result), + opts() +) -> + Result | {error, auth_error()} +when + Result :: term(). +with_api(Callbacks, Permission, BaseConfig, Fun, Opts) -> + Optional = proplists:get_value(optional, Opts, false), + AuthInline = proplists:get_value(auth_inline, Opts, true), + case resolve_api_auth(Callbacks, Permission, BaseConfig) of + {ok, ApiKey, AuthContext} -> + Config = BaseConfig#{api_key => ApiKey}, + execute_with_retry(Callbacks, Config, Fun, AuthContext, 0, undefined); + {error, no_auth} when Optional =:= true -> + %% Auth is optional, try without credentials first + execute_optional_with_retry(Callbacks, BaseConfig, Fun, Opts); + {error, no_auth} when AuthInline =:= true -> + %% No auth found, ask user if they want to authenticate + maybe_authenticate_and_retry(Callbacks, BaseConfig, Fun, no_credentials, Opts); + {error, no_auth} -> + %% auth_inline is false, just return error + {error, {auth_error, no_credentials}}; + {error, _} = Error -> + Error + end. + +%% @doc +%% Execute a function with repository authentication. +%% +%% Equivalent to `with_repo(Callbacks, Config, Fun, [])'. +%% +%% @see with_repo/4 +-spec with_repo(callbacks(), mix_hex_core:config(), fun((mix_hex_core:config()) -> Result)) -> + Result | {error, auth_error()} +when + Result :: term(). +with_repo(Callbacks, BaseConfig, Fun) -> + with_repo(Callbacks, 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'). +%% +%% Options: +%%
    +%%
  • `optional' - When `true' (default), proceeds without auth if none found; retries with auth on 401.
  • +%%
  • `auth_inline' - When `true', prompts user via `should_authenticate' callback. Default is `false'.
  • +%%
  • `oauth_open_browser' - When `true' (default), automatically opens the browser +%% during device auth flow. When `false', only prints the URL for the user.
  • +%%
+%% +%% Example: +%% ``` +%% mix_hex_cli_auth:with_repo(Callbacks, Config, fun(C) -> +%% mix_hex_repo:get_tarball(C, <<"ecto">>, <<"3.0.0">>) +%% end). +%% ''' +-spec with_repo( + callbacks(), mix_hex_core:config(), fun((mix_hex_core:config()) -> Result), opts() +) -> + Result | {error, auth_error()} +when + Result :: term(). +with_repo(Callbacks, BaseConfig, Fun, Opts) -> + Optional = proplists:get_value(optional, Opts, true), + AuthInline = proplists:get_value(auth_inline, Opts, false), + case resolve_repo_auth(Callbacks, 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(Callbacks, BaseConfig, Fun, Opts); + no_auth when AuthInline =:= true -> + %% No auth found, ask user if they want to authenticate + maybe_authenticate_and_retry(Callbacks, BaseConfig, Fun, no_credentials, Opts); + no_auth -> + %% auth_inline is false, return error + {error, {auth_error, no_credentials}}; + {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. +maybe_authenticate_and_retry(Callbacks, BaseConfig, Fun, Reason, Opts) -> + case call_callback(Callbacks, should_authenticate, [Reason]) of + true -> + case device_auth(Callbacks, 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(Callbacks, Config, Fun, AuthContext, 0, undefined); + {error, _} = Error -> + Error + end; + false -> + {error, {auth_error, no_credentials}} + end. + +%% @private +%% Execute function without auth, but retry with auth if we get a 401. +execute_optional_with_retry(Callbacks, 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(Callbacks, 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(callbacks(), mix_hex_core:config(), binary(), opts()) -> + {ok, oauth_tokens()} | {error, auth_error()}. +device_auth(Callbacks, Config, Scope, Opts) -> + ClientId = call_callback(Callbacks, 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(Callbacks, 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(callbacks(), permission(), mix_hex_core:config()) -> + {ok, binary(), auth_context()} | {error, no_auth} | {error, auth_error()}. +resolve_api_auth(_Callbacks, _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(Callbacks, _Permission, Config) -> + RepoName = repo_name(Config), + %% 1. Check per-repo api_key + case call_callback(Callbacks, 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(Callbacks, 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(Callbacks, 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/4 for optional/auth_inline) +-spec resolve_repo_auth(callbacks(), mix_hex_core:config()) -> + {ok, binary(), auth_context()} | no_auth | {error, auth_error()}. +resolve_repo_auth(_Callbacks, #{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(Callbacks, Config) -> + RepoName = repo_name(Config), + global:trans( + {{?MODULE, repo}, RepoName}, + fun() -> + do_resolve_repo_auth(Callbacks, RepoName, RepoName, Config) + end, + [], + infinity + ). + +do_resolve_repo_auth(Callbacks, RepoName, LookupRepo, Config) -> + Trusted = maps:get(trusted, Config, false), + OAuthExchange = maps:get(oauth_exchange, Config, false), + case call_callback(Callbacks, 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(Callbacks, 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(Callbacks, 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(Callbacks, RepoName, ParentName, Config); + _ -> + %% 6. trusted + global OAuth tokens => use those + resolve_global_oauth_for_repo(Callbacks, Config) + end; + _ -> + %% 7. Not trusted, no auth + no_auth + end. + +%% @private +resolve_global_oauth_for_repo(Callbacks, Config) -> + case resolve_oauth_token_with_context(Callbacks, 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(Callbacks, 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(Callbacks, 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(Callbacks, RepoName, Config, AuthKey, Scope) -> + ClientId = call_callback(Callbacks, 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(Callbacks, 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(Callbacks, RepoName, KeyType) -> + case binary:split(RepoName, <<":">>) of + [ParentName, _OrgName] -> + case call_callback(Callbacks, 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(Callbacks, Config) -> + global:trans( + {{?MODULE, token_refresh}, self()}, + fun() -> + do_resolve_oauth_token_with_context(Callbacks, Config) + end, + [], + infinity + ). + +%% @private +do_resolve_oauth_token_with_context(Callbacks, Config) -> + case call_callback(Callbacks, 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(Callbacks, 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(Callbacks, Config, #{refresh_token := RefreshToken}) when + is_binary(RefreshToken) +-> + ClientId = call_callback(Callbacks, 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(Callbacks, 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(_Callbacks, _Config, _Tokens) -> + {error, {auth_error, token_refresh_failed}}. + +%%==================================================================== +%% Internal functions - Retry Logic +%%==================================================================== + +%% @private +execute_with_retry(Callbacks, Config, Fun, AuthContext, OtpRetries, LastOtpError) -> + case Fun(Config) of + {error, otp_required} -> + handle_otp_retry( + Callbacks, Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">> + ); + {error, invalid_totp} -> + handle_otp_retry( + Callbacks, + Config, + Fun, + AuthContext, + OtpRetries, + <<"Invalid OTP code. Please try again:">> + ); + {ok, {401, Headers, _Body}} = Response -> + case detect_auth_error(Headers) of + otp_required -> + handle_otp_retry( + Callbacks, Config, Fun, AuthContext, OtpRetries, <<"Enter OTP code:">> + ); + invalid_totp -> + Msg = + case LastOtpError of + invalid_totp -> <<"Invalid OTP code. Please try again:">>; + _ -> <<"Enter OTP code:">> + end, + handle_otp_retry(Callbacks, Config, Fun, AuthContext, OtpRetries, Msg); + token_expired -> + handle_token_refresh_retry(Callbacks, Config, Fun, AuthContext); + none -> + Response + end; + Other -> + Other + end. + +%% @private +handle_otp_retry(_Callbacks, _Config, _Fun, _AuthContext, OtpRetries, _Message) when + OtpRetries >= ?MAX_OTP_RETRIES +-> + {error, {auth_error, otp_max_retries}}; +handle_otp_retry(Callbacks, Config, Fun, AuthContext, OtpRetries, Message) -> + case call_callback(Callbacks, prompt_otp, [Message]) of + {ok, OtpCode} -> + NewConfig = Config#{api_otp => OtpCode}, + execute_with_retry( + Callbacks, NewConfig, Fun, AuthContext, OtpRetries + 1, invalid_totp + ); + cancelled -> + {error, {auth_error, otp_cancelled}} + end. + +%% @private +handle_token_refresh_retry(Callbacks, Config, Fun, AuthContext) -> + %% 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(Callbacks, Config) of + {ok, NewBearerToken, NewAuthContext} -> + NewConfig = Config#{api_key => NewBearerToken}, + execute_with_retry(Callbacks, NewConfig, Fun, NewAuthContext, 0, undefined); + {error, _} -> + {error, {auth_error, token_refresh_failed}} + end; + 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(Callbacks, Name, Args) -> + Fun = maps:get(Name, Callbacks), + erlang:apply(Fun, Args). diff --git a/src/mix_hex_core.erl b/src/mix_hex_core.erl index ff2ef8da..bacf6de6 100644 --- a/src/mix_hex_core.erl +++ b/src/mix_hex_core.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% `hex_core' entrypoint module. @@ -113,7 +113,10 @@ tarball_max_size => pos_integer() | infinity, tarball_max_uncompressed_size => pos_integer() | infinity, docs_tarball_max_size => pos_integer() | infinity, - docs_tarball_max_uncompressed_size => pos_integer() | infinity + docs_tarball_max_uncompressed_size => pos_integer() | infinity, + trusted => boolean(), + oauth_exchange => boolean(), + oauth_exchange_url => binary() | undefined }. -spec default_config() -> config(). @@ -139,5 +142,8 @@ default_config() -> tarball_max_size => 16 * 1024 * 1024, tarball_max_uncompressed_size => 128 * 1024 * 1024, docs_tarball_max_size => 16 * 1024 * 1024, - docs_tarball_max_uncompressed_size => 128 * 1024 * 1024 + docs_tarball_max_uncompressed_size => 128 * 1024 * 1024, + trusted => true, + oauth_exchange => true, + oauth_exchange_url => undefined }. diff --git a/src/mix_hex_core.hrl b/src/mix_hex_core.hrl index a073d9af..8831d340 100644 --- a/src/mix_hex_core.hrl +++ b/src/mix_hex_core.hrl @@ -1,3 +1,3 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually -define(HEX_CORE_VERSION, "0.15.0"). diff --git a/src/mix_hex_erl_tar.erl b/src/mix_hex_erl_tar.erl index 43966046..cc35259f 100644 --- a/src/mix_hex_erl_tar.erl +++ b/src/mix_hex_erl_tar.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 d4236023..e3765202 100644 --- a/src/mix_hex_erl_tar.hrl +++ b/src/mix_hex_erl_tar.hrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 693bbc21..9773798a 100644 --- a/src/mix_hex_http.erl +++ b/src/mix_hex_http.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% HTTP contract. diff --git a/src/mix_hex_http_httpc.erl b/src/mix_hex_http_httpc.erl index 78b76738..f3166e7f 100644 --- a/src/mix_hex_http_httpc.erl +++ b/src/mix_hex_http_httpc.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 d911c9fd..cfb3c3ec 100644 --- a/src/mix_hex_licenses.erl +++ b/src/mix_hex_licenses.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% Hex Licenses. diff --git a/src/mix_hex_pb_names.erl b/src/mix_hex_pb_names.erl index f9101338..23973d94 100644 --- a/src/mix_hex_pb_names.erl +++ b/src/mix_hex_pb_names.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 a0432043..501d9989 100644 --- a/src/mix_hex_pb_package.erl +++ b/src/mix_hex_pb_package.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 4afa5173..d5f60e0a 100644 --- a/src/mix_hex_pb_signed.erl +++ b/src/mix_hex_pb_signed.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 9d228d71..f1a273e9 100644 --- a/src/mix_hex_pb_versions.erl +++ b/src/mix_hex_pb_versions.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 e02c4ea1..0b8a73d7 100644 --- a/src/mix_hex_registry.erl +++ b/src/mix_hex_registry.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% Functions for encoding and decoding Hex registries. diff --git a/src/mix_hex_repo.erl b/src/mix_hex_repo.erl index 78fd81f0..47f5128c 100644 --- a/src/mix_hex_repo.erl +++ b/src/mix_hex_repo.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %% @doc %% Repo API. diff --git a/src/mix_hex_safe_binary_to_term.erl b/src/mix_hex_safe_binary_to_term.erl index 5d9f46df..01464780 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.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 692f9664..70155971 100644 --- a/src/mix_hex_tarball.erl +++ b/src/mix_hex_tarball.erl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), 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 4f7e0b2c..5e4bb836 100644 --- a/src/mix_safe_erl_term.xrl +++ b/src/mix_safe_erl_term.xrl @@ -1,4 +1,4 @@ -%% Vendored from hex_core v0.15.0 (d341c2e), do not edit manually +%% Vendored from hex_core v0.15.0 (b8cea90), do not edit manually %%% Author : Robert Virding %%% Purpose : Token definitions for Erlang. 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/oauth_test.exs b/test/hex/oauth_test.exs deleted file mode 100644 index 22f508d1..00000000 --- a/test/hex/oauth_test.exs +++ /dev/null @@ -1,267 +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() - 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() - 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/repo_test.exs b/test/hex/repo_test.exs index 076ae91f..474be367 100644 --- a/test/hex/repo_test.exs +++ b/test/hex/repo_test.exs @@ -110,7 +110,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") @@ -130,7 +130,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) @@ -150,7 +150,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 @@ -175,7 +175,7 @@ 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 @@ -210,7 +210,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 @@ -268,14 +268,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 @@ -284,8 +284,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) @@ -296,7 +296,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 @@ -340,7 +340,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) @@ -350,7 +350,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 @@ -370,8 +370,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) @@ -382,7 +382,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 diff --git a/test/mix/tasks/hex.organization_test.exs b/test/mix/tasks/hex.organization_test.exs index 0b705505..2cf8b8b8 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() @@ -51,7 +51,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) @@ -74,14 +74,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..e933be3c 100644 --- a/test/mix/tasks/hex.user_test.exs +++ b/test/mix/tasks/hex.user_test.exs @@ -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() @@ -505,54 +306,4 @@ defmodule Mix.Tasks.Hex.UserTest do Hex.API.OAuth.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 - - 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) - 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) - - assert [key: "oauth_token", oauth: true] = auth - end) - end end diff --git a/test/support/case.ex b/test/support/case.ex index 724288fb..d3c27722 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -274,7 +274,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