Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,24 @@ Publish package tarball:
{ok, {200, _Headers, _Body} = hex_api_package:publish(Config, Tarball).
```

### Two-Factor Authentication

When using OAuth tokens, two-factor authentication may be required. If required, the server will return `{error, otp_required}` and you should retry the request with the TOTP code via the `api_otp` configuration option:

```erlang
%% First attempt without OTP
case hex_api_release:publish(Config, Tarball) of
{error, otp_required} ->
%% Retry with TOTP code
ConfigWithOTP = Config#{api_otp := <<"123456">>},
hex_api_release:publish(ConfigWithOTP, Tarball);
Result ->
Result
end.
```

API keys don't require TOTP validation.

### Package tarballs

Unpack package tarball:
Expand Down
21 changes: 19 additions & 2 deletions src/hex_api.erl
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,13 @@ request(Config, Method, Path, Body) when is_binary(Path) and is_map(Config) ->
case hex_http:request(Config, Method, build_url(Path, Config), ReqHeaders2, Body) of
{ok, {Status, RespHeaders, RespBody}} ->
ContentType = maps:get(<<"content-type">>, RespHeaders, <<"">>),
case binary:match(ContentType, ?ERL_CONTENT_TYPE) of
Response = case binary:match(ContentType, ?ERL_CONTENT_TYPE) of
{_, _} ->
{ok, {Status, RespHeaders, binary_to_term(RespBody)}};
nomatch ->
{ok, {Status, RespHeaders, nil}}
end;
end,
detect_otp_error(Response);
Other ->
Other
end.
Expand All @@ -133,6 +134,8 @@ make_headers(Config) ->
%% @private
set_header(api_key, Token, Headers) when is_binary(Token) ->
maps:put(<<"authorization">>, Token, Headers);
set_header(api_otp, OTP, Headers) when is_binary(OTP) ->
maps:put(<<"x-hex-otp">>, OTP, Headers);
set_header(_, _, Headers) ->
Headers.

Expand Down Expand Up @@ -161,3 +164,17 @@ to_list(A) when is_atom(A) -> atom_to_list(A);
to_list(B) when is_binary(B) -> unicode:characters_to_list(B);
to_list(I) when is_integer(I) -> integer_to_list(I);
to_list(Str) -> unicode:characters_to_list(Str).

%% TODO: not needed after exdoc is fixed
%% @private
detect_otp_error({ok, {401, Headers, Body}}) ->
case maps:get(<<"www-authenticate">>, Headers, nil) of
<<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> ->
{error, otp_required};
<<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> ->
{error, invalid_totp};
_ ->
{ok, {401, Headers, Body}}
end;
detect_otp_error(Response) ->
Response.
34 changes: 25 additions & 9 deletions src/hex_api_release.erl
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,24 @@ publish(Config, Tarball) -> publish(Config, Tarball, []).
publish(Config, Tarball, Params) when
is_map(Config) andalso is_binary(Tarball) andalso is_list(Params)
->
QueryString = hex_api:encode_query_string([
{replace, proplists:get_value(replace, Params, false)}
]),
Path = hex_api:join_path_segments(hex_api:build_repository_path(Config, ["publish"])),
PathWithQuery = <<Path/binary, "?", QueryString/binary>>,
TarballContentType = "application/octet-stream",
Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config),
Body = {TarballContentType, Tarball},
hex_api:post(Config2, PathWithQuery, Body).
case hex_tarball:unpack(Tarball, memory) of
{ok, #{metadata := Metadata}} ->
PackageName = maps:get(<<"name">>, Metadata),
QueryString = hex_api:encode_query_string([
{replace, proplists:get_value(replace, Params, false)}
]),
Path = hex_api:join_path_segments(
hex_api:build_repository_path(Config, ["packages", PackageName, "releases"])
),
PathWithQuery = <<Path/binary, "?", QueryString/binary>>,
TarballContentType = "application/octet-stream",
Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config),
Config3 = maybe_put_expect_header(Config2),
Body = {TarballContentType, Tarball},
hex_api:post(Config3, PathWithQuery, Body);
{error, Reason} ->
{error, {tarball, Reason}}
end.

%% @doc
%% Deletes a package release.
Expand Down Expand Up @@ -171,3 +180,10 @@ put_header(Name, Value, Config) ->
Headers = maps:get(http_headers, Config, #{}),
Headers2 = maps:put(Name, Value, Headers),
maps:put(http_headers, Headers2, Config).

%% @private
maybe_put_expect_header(Config) ->
case maps:get(send_100_continue, Config, true) of
true -> put_header(<<"expect">>, <<"100-continue">>, Config);
false -> Config
end.
17 changes: 17 additions & 0 deletions src/hex_core.erl
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@
%%
%% * `api_key' - Authentication key used when accessing the HTTP API.
%%
%% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication.
%% When using OAuth tokens, write operations require 2FA if the user has it enabled.
%% If required, the server returns one of:
%% - `{error, otp_required}' - Retry the request with a 6-digit TOTP code in this option
%% - `{error, invalid_totp}' - The provided TOTP code was incorrect, retry with correct code
%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' - User must enable 2FA first
%% - `{ok, {429, _, _}}' - Too many failed TOTP attempts, rate limited
%% API keys do not require TOTP validation.
%%
%% * `api_organization' - Name of the organization endpoint in the API, this should
%% for example be set when accessing key for a specific organization.
%%
Expand Down Expand Up @@ -47,6 +56,10 @@
%% * `repo_verify_origin' - If `true' will verify the repository signature origin,
%% requires protobuf messages as of hex_core v0.4.0 (default: `true').
%%
%% * `send_100_continue' - If `true' will send `Expect: 100-continue' header for
%% publish operations. This allows the server to validate authentication and
%% authorization before the client sends the request body (default: `true').
%%
%% * `tarball_max_size' - Maximum size of package tarball, defaults to
%% `16_777_216' (16 MiB). Set to `infinity' to not enforce the limit.
%%
Expand Down Expand Up @@ -79,6 +92,7 @@

-type config() :: #{
api_key => binary() | undefined,
api_otp => binary() | undefined,
api_organization => binary() | undefined,
api_repository => binary() | undefined,
api_url => binary(),
Expand All @@ -93,6 +107,7 @@
repo_organization => binary() | undefined,
repo_verify => boolean(),
repo_verify_origin => boolean(),
send_100_continue => boolean(),
tarball_max_size => pos_integer() | infinity,
tarball_max_uncompressed_size => pos_integer() | infinity,
docs_tarball_max_size => pos_integer() | infinity,
Expand All @@ -103,6 +118,7 @@
default_config() ->
#{
api_key => undefined,
api_otp => undefined,
api_organization => undefined,
api_repository => undefined,
api_url => <<"https://hex.pm/api">>,
Expand All @@ -117,6 +133,7 @@ default_config() ->
repo_organization => undefined,
repo_verify => true,
repo_verify_origin => true,
send_100_continue => true,
tarball_max_size => 16 * 1024 * 1024,
tarball_max_uncompressed_size => 128 * 1024 * 1024,
docs_tarball_max_size => 16 * 1024 * 1024,
Expand Down
2 changes: 2 additions & 0 deletions src/hex_repo.erl
Original file line number Diff line number Diff line change
Expand Up @@ -254,5 +254,7 @@ set_header(http_etag, ETag, Headers) when is_binary(ETag) ->
maps:put(<<"if-none-match">>, ETag, Headers);
set_header(repo_key, Token, Headers) when is_binary(Token) ->
maps:put(<<"authorization">>, Token, Headers);
set_header(api_otp, OTP, Headers) when is_binary(OTP) ->
maps:put(<<"x-hex-otp">>, OTP, Headers);
set_header(_, _, Headers) ->
Headers.
33 changes: 30 additions & 3 deletions test/hex_api_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ suite() ->

all() ->
[package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test,
oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test].
oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test,
publish_with_expect_header_test, publish_without_expect_header_test].

package_test(_Config) ->
{ok, {200, _, Package}} = hex_api_package:get(?CONFIG, <<"ecto">>),
Expand All @@ -45,7 +46,9 @@ release_test(_Config) ->
ok.

publish_test(_Config) ->
{ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>),
Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>},
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
{ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, Tarball),
#{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release,
#{
<<"decimal">> := #{
Expand All @@ -55,7 +58,9 @@ publish_test(_Config) ->
ok.

replace_test(_Config) ->
{ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>, [
Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>},
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
{ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, Tarball, [
{replace, true}
]),
#{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release,
Expand Down Expand Up @@ -168,3 +173,25 @@ oauth_revoke_test(_Config) ->
NonExistentToken = <<"non_existent_token">>,
{ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken),
ok.

publish_with_expect_header_test(_Config) ->
% Test that send_100_continue => true includes Expect: 100-continue header
Metadata = #{<<"name">> => <<"expect_test">>, <<"version">> => <<"1.0.0">>},
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),

% Default config has send_100_continue => true
Config = ?CONFIG,
{ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball),
#{<<"version">> := <<"1.0.0">>} = Release,
ok.

publish_without_expect_header_test(_Config) ->
% Test that send_100_continue => false does not include Expect header
Metadata = #{<<"name">> => <<"no_expect_test">>, <<"version">> => <<"1.0.0">>},
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),

% Explicitly disable send_100_continue
Config = maps:put(send_100_continue, false, ?CONFIG),
{ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball),
#{<<"version">> := <<"1.0.0">>} = Release,
ok.
38 changes: 34 additions & 4 deletions test/support/hex_http_test.erl
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,39 @@ fixture(get, <<?TEST_API_URL, "/packages/ecto/releases/1.0.0">>, _, _) ->
},
{ok, {200, api_headers(), term_to_binary(Payload)}};

%% /publish
%% /packages/:name/releases - test expect header presence

fixture(get, <<?TEST_API_URL, "/publish">>, _, _) ->
fixture(post, <<?TEST_API_URL, "/packages/expect_test/releases?replace=false">>, Headers, _) ->
% Verify that Expect: 100-continue header is present
case maps:get(<<"expect">>, Headers, undefined) of
<<"100-continue">> ->
Payload = #{
<<"version">> => <<"1.0.0">>,
<<"requirements">> => #{}
},
{ok, {200, api_headers(), term_to_binary(Payload)}};
_ ->
error({expect_header_missing, Headers})
end;

%% /packages/:name/releases - test expect header absence

fixture(post, <<?TEST_API_URL, "/packages/no_expect_test/releases?replace=false">>, Headers, _) ->
% Verify that Expect header is NOT present
case maps:get(<<"expect">>, Headers, undefined) of
undefined ->
Payload = #{
<<"version">> => <<"1.0.0">>,
<<"requirements">> => #{}
},
{ok, {200, api_headers(), term_to_binary(Payload)}};
Value ->
error({expect_header_present, Value})
end;

%% /packages/:name/releases

fixture(post, <<?TEST_API_URL, "/packages/ecto/releases?replace=false">>, _, _) ->
Payload = #{
<<"version">> => <<"1.0.0">>,
<<"requirements">> => #{
Expand All @@ -173,9 +203,9 @@ fixture(get, <<?TEST_API_URL, "/publish">>, _, _) ->
},
{ok, {200, api_headers(), term_to_binary(Payload)}};

%% /publish?replace=true
%% /packages/:name/releases?replace=true

fixture(post, <<?TEST_API_URL, "/publish?replace=true">>, _, _) ->
fixture(post, <<?TEST_API_URL, "/packages/ecto/releases?replace=true">>, _, _) ->
Payload = #{
<<"version">> => <<"1.0.0">>,
<<"requirements">> => #{
Expand Down