From 1214d347fbc3301f364389d3557665e2baa78ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 5 Oct 2025 20:24:09 +0200 Subject: [PATCH 1/5] Add 2FA for write API requests --- README.md | 14 ++++++++++++++ src/hex_api.erl | 2 ++ src/hex_api_key.erl | 18 ++++++++++++++++++ src/hex_api_package_owner.erl | 12 ++++++++++++ src/hex_api_release.erl | 32 ++++++++++++++++++++++++++++++++ src/hex_core.erl | 6 ++++++ src/hex_repo.erl | 2 ++ 7 files changed, 86 insertions(+) diff --git a/README.md b/README.md index 95089e9..46df0f0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,20 @@ Publish package tarball: {ok, {200, _Headers, _Body} = hex_api_package:publish(Config, Tarball). ``` +### Two-Factor Authentication + +When using OAuth tokens for write operations, you must include the TOTP code via the `api_otp` configuration option: + +```erlang +%% Add TOTP code to config +Config = maps:put(api_otp, <<"123456">>, hex_core:default_config()). + +%% Use for write operations (publish, delete, etc.) +hex_api_release:publish(Config, Tarball). +``` + +The TOTP code is required for write API endpoints when using OAuth tokens. API keys don't require TOTP validation. + ### Package tarballs Unpack package tarball: diff --git a/src/hex_api.erl b/src/hex_api.erl index 2b49168..b71bc89 100644 --- a/src/hex_api.erl +++ b/src/hex_api.erl @@ -133,6 +133,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. diff --git a/src/hex_api_key.erl b/src/hex_api_key.erl index f16743f..fe0a480 100644 --- a/src/hex_api_key.erl +++ b/src/hex_api_key.erl @@ -76,6 +76,12 @@ get(Config, Name) when is_map(Config) and is_binary(Name) -> %% %% Valid `Resource' values: `<<"read">> | <<"write">>'. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link hex_api_release:publish/3} for +%% possible 2FA-related error responses. +%% %% Examples: %% %% ``` @@ -104,6 +110,12 @@ add(Config, Name, Permissions) when is_map(Config) and is_binary(Name) and is_li %% @doc %% Deletes an API or repository key. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link hex_api_release:publish/3} for +%% possible 2FA-related error responses. +%% %% Examples: %% %% ``` @@ -131,6 +143,12 @@ delete(Config, Name) when is_map(Config) and is_binary(Name) -> %% @doc %% Deletes all API and repository keys associated with the account. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link hex_api_release:publish/3} for +%% possible 2FA-related error responses. +%% %% Examples: %% %% ``` diff --git a/src/hex_api_package_owner.erl b/src/hex_api_package_owner.erl index 0250b65..9b7bb7f 100644 --- a/src/hex_api_package_owner.erl +++ b/src/hex_api_package_owner.erl @@ -63,6 +63,12 @@ get(Config, PackageName, UsernameOrEmail) when %% @doc %% Adds a packages owner. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link hex_api_release:publish/3} for +%% possible 2FA-related error responses. +%% %% Examples: %% %% ``` @@ -92,6 +98,12 @@ add(Config, PackageName, UsernameOrEmail, Level, Transfer) when %% @doc %% Deletes a packages owner. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link hex_api_release:publish/3} for +%% possible 2FA-related error responses. +%% %% Examples: %% %% ``` diff --git a/src/hex_api_release.erl b/src/hex_api_release.erl index 96d3c3d..cb15717 100644 --- a/src/hex_api_release.erl +++ b/src/hex_api_release.erl @@ -79,6 +79,16 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). %% Supported query params : %% - replace : boolean %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. Possible 2FA-related errors: +%% +%% - `{ok, {401, _, #{<<"message">> => <<"Two-factor authentication required. Include X-Hex-OTP header with your TOTP code.">>}}}' +%% - `{ok, {401, _, #{<<"message">> => <<"Invalid two-factor authentication code">>}}}' +%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' +%% - `{ok, {429, _, #{<<"message">> => <<"Too many failed two-factor authentication attempts. Please try again later.">>}}}' +%% %% Examples: %% %% ``` @@ -99,6 +109,10 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). %% <<"url">> => <<"https://hex.pm/api/packages/package/releases/1.0.0">>, %% <<"version">> => <<"1.0.0">> %% }}} +%% +%% %% With 2FA +%% > Config = maps:put(api_otp, <<"123456">>, hex_core:default_config()). +%% > hex_api_release:publish(Config, Tarball). %% ''' %% @end -spec publish(hex_core:config(), binary(), publish_params()) -> hex_api:response(). @@ -118,6 +132,12 @@ publish(Config, Tarball, Params) when %% @doc %% Deletes a package release. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link publish/3} for possible 2FA-related +%% error responses. +%% %% Examples: %% %% ``` @@ -133,6 +153,12 @@ delete(Config, Name, Version) when is_map(Config) and is_binary(Name) and is_bin %% @doc %% Retires a package release. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link publish/3} for possible 2FA-related +%% error responses. +%% %% Examples: %% %% ``` @@ -150,6 +176,12 @@ retire(Config, Name, Version, Params) when %% @doc %% Unretires a package release. %% +%% === Two-Factor Authentication === +%% +%% When using OAuth tokens, you must provide the TOTP code via the +%% `api_otp' config option. See {@link publish/3} for possible 2FA-related +%% error responses. +%% %% Examples: %% %% ``` diff --git a/src/hex_core.erl b/src/hex_core.erl index 04b26f5..a44175b 100644 --- a/src/hex_core.erl +++ b/src/hex_core.erl @@ -12,6 +12,10 @@ %% %% * `api_key' - Authentication key used when accessing the HTTP API. %% +%% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication. +%% Required for write operations when using OAuth tokens. +%% The 6-digit code from your authenticator app. +%% %% * `api_organization' - Name of the organization endpoint in the API, this should %% for example be set when accessing key for a specific organization. %% @@ -79,6 +83,7 @@ -type config() :: #{ api_key => binary() | undefined, + api_otp => binary() | undefined, api_organization => binary() | undefined, api_repository => binary() | undefined, api_url => binary(), @@ -103,6 +108,7 @@ default_config() -> #{ api_key => undefined, + api_otp => undefined, api_organization => undefined, api_repository => undefined, api_url => <<"https://hex.pm/api">>, diff --git a/src/hex_repo.erl b/src/hex_repo.erl index 8068636..08728b7 100644 --- a/src/hex_repo.erl +++ b/src/hex_repo.erl @@ -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. From 740b084877b0e9834a17cdda84686e3d8c4ba5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 6 Oct 2025 17:52:20 +0200 Subject: [PATCH 2/5] Support 100 continue and error codes --- README.md | 18 +++++++++++------- src/hex_api.erl | 19 +++++++++++++++++-- src/hex_api_key.erl | 18 +++++++++--------- src/hex_api_package_owner.erl | 12 ++++++------ src/hex_api_release.erl | 24 +++++++++++++++++------- src/hex_core.erl | 9 ++++++++- 6 files changed, 68 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 46df0f0..c107c15 100644 --- a/README.md +++ b/README.md @@ -92,17 +92,21 @@ Publish package tarball: ### Two-Factor Authentication -When using OAuth tokens for write operations, you must include the TOTP code via the `api_otp` configuration option: +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 -%% Add TOTP code to config -Config = maps:put(api_otp, <<"123456">>, hex_core:default_config()). - -%% Use for write operations (publish, delete, etc.) -hex_api_release:publish(Config, Tarball). +%% 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. ``` -The TOTP code is required for write API endpoints when using OAuth tokens. API keys don't require TOTP validation. +API keys don't require TOTP validation. ### Package tarballs diff --git a/src/hex_api.erl b/src/hex_api.erl index b71bc89..48f9e93 100644 --- a/src/hex_api.erl +++ b/src/hex_api.erl @@ -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. @@ -163,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. diff --git a/src/hex_api_key.erl b/src/hex_api_key.erl index fe0a480..b61105c 100644 --- a/src/hex_api_key.erl +++ b/src/hex_api_key.erl @@ -78,9 +78,9 @@ get(Config, Name) when is_map(Config) and is_binary(Name) -> %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link hex_api_release:publish/3} for -%% possible 2FA-related error responses. +%% When using OAuth tokens, two-factor authentication may be required. +%% See {@link hex_api_release:publish/3} for possible 2FA-related error +%% responses and handling. %% %% Examples: %% @@ -112,9 +112,9 @@ add(Config, Name, Permissions) when is_map(Config) and is_binary(Name) and is_li %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link hex_api_release:publish/3} for -%% possible 2FA-related error responses. +%% When using OAuth tokens, two-factor authentication may be required. +%% See {@link hex_api_release:publish/3} for possible 2FA-related error +%% responses and handling. %% %% Examples: %% @@ -145,9 +145,9 @@ delete(Config, Name) when is_map(Config) and is_binary(Name) -> %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link hex_api_release:publish/3} for -%% possible 2FA-related error responses. +%% When using OAuth tokens, two-factor authentication may be required. +%% See {@link hex_api_release:publish/3} for possible 2FA-related error +%% responses and handling. %% %% Examples: %% diff --git a/src/hex_api_package_owner.erl b/src/hex_api_package_owner.erl index 9b7bb7f..4e1644b 100644 --- a/src/hex_api_package_owner.erl +++ b/src/hex_api_package_owner.erl @@ -65,9 +65,9 @@ get(Config, PackageName, UsernameOrEmail) when %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link hex_api_release:publish/3} for -%% possible 2FA-related error responses. +%% When using OAuth tokens, two-factor authentication may be required. +%% See {@link hex_api_release:publish/3} for possible 2FA-related error +%% responses and handling. %% %% Examples: %% @@ -100,9 +100,9 @@ add(Config, PackageName, UsernameOrEmail, Level, Transfer) when %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link hex_api_release:publish/3} for -%% possible 2FA-related error responses. +%% When using OAuth tokens, two-factor authentication may be required. +%% See {@link hex_api_release:publish/3} for possible 2FA-related error +%% responses and handling. %% %% Examples: %% diff --git a/src/hex_api_release.erl b/src/hex_api_release.erl index cb15717..e305e8c 100644 --- a/src/hex_api_release.erl +++ b/src/hex_api_release.erl @@ -81,13 +81,15 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). %% %% === Two-Factor Authentication === %% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. Possible 2FA-related errors: +%% When using OAuth tokens, two-factor authentication may be required. +%% If required, the server will respond with `{error, otp_required}' and +%% you should retry the request with the TOTP code in the `api_otp' config option. %% -%% - `{ok, {401, _, #{<<"message">> => <<"Two-factor authentication required. Include X-Hex-OTP header with your TOTP code.">>}}}' -%% - `{ok, {401, _, #{<<"message">> => <<"Invalid two-factor authentication code">>}}}' -%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' -%% - `{ok, {429, _, #{<<"message">> => <<"Too many failed two-factor authentication attempts. Please try again later.">>}}}' +%% Possible 2FA-related errors: +%% - `{error, otp_required}' - OTP code is required, retry with `api_otp' set +%% - `{error, invalid_totp}' - OTP code was invalid, retry with correct code +%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' - User must enable 2FA +%% - `{ok, {429, _, #{<<"message">> => <<"Too many failed two-factor authentication attempts. Please try again later.">>}}}' - Rate limited %% %% Examples: %% @@ -126,8 +128,9 @@ publish(Config, Tarball, Params) when PathWithQuery = <>, 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(Config2, PathWithQuery, Body). + hex_api:post(Config3, PathWithQuery, Body). %% @doc %% Deletes a package release. @@ -203,3 +206,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. diff --git a/src/hex_core.erl b/src/hex_core.erl index a44175b..768e248 100644 --- a/src/hex_core.erl +++ b/src/hex_core.erl @@ -13,7 +13,8 @@ %% * `api_key' - Authentication key used when accessing the HTTP API. %% %% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication. -%% Required for write operations when using OAuth tokens. +%% May be required for write operations when using OAuth tokens. If required, the +%% server will return `{error, otp_required}' and you should retry with this option set. %% The 6-digit code from your authenticator app. %% %% * `api_organization' - Name of the organization endpoint in the API, this should @@ -51,6 +52,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. %% @@ -98,6 +103,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, @@ -123,6 +129,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, From 0f71a5724cac2d8cf949b13d5e8fc56a8ca623e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 6 Oct 2025 18:53:34 +0200 Subject: [PATCH 3/5] Use old publish endpoint It supports better validation with 100 continue --- src/hex_api_release.erl | 28 ++++++++++++++++++---------- test/hex_api_SUITE.erl | 8 ++++++-- test/support/hex_http_test.erl | 8 ++++---- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/hex_api_release.erl b/src/hex_api_release.erl index e305e8c..fe8988f 100644 --- a/src/hex_api_release.erl +++ b/src/hex_api_release.erl @@ -121,16 +121,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 = <>, - 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). + 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 = <>, + 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. diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index daedc27..74b6676 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -45,7 +45,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">> := #{ @@ -55,7 +57,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, diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index ec3a039..941395e 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -158,9 +158,9 @@ fixture(get, <>, _, _) -> }, {ok, {200, api_headers(), term_to_binary(Payload)}}; -%% /publish +%% /packages/:name/releases -fixture(get, <>, _, _) -> +fixture(post, <>, _, _) -> Payload = #{ <<"version">> => <<"1.0.0">>, <<"requirements">> => #{ @@ -173,9 +173,9 @@ fixture(get, <>, _, _) -> }, {ok, {200, api_headers(), term_to_binary(Payload)}}; -%% /publish?replace=true +%% /packages/:name/releases?replace=true -fixture(post, <>, _, _) -> +fixture(post, <>, _, _) -> Payload = #{ <<"version">> => <<"1.0.0">>, <<"requirements">> => #{ From 5cb699ec7104e49da7bef5a682bb417e63d6f46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 6 Oct 2025 21:11:30 +0200 Subject: [PATCH 4/5] Add tests for expect header --- test/hex_api_SUITE.erl | 25 ++++++++++++++++++++++++- test/support/hex_http_test.erl | 30 ++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index 74b6676..2821e93 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -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">>), @@ -172,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. diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 941395e..9397afb 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -158,6 +158,36 @@ fixture(get, <>, _, _) -> }, {ok, {200, api_headers(), term_to_binary(Payload)}}; +%% /packages/:name/releases - test expect header presence + +fixture(post, <>, 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, <>, 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, <>, _, _) -> From 9701115223a596ba81fbdcddca46f64fb8419d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 7 Oct 2025 01:26:49 +0200 Subject: [PATCH 5/5] Clean up 2FA docs --- src/hex_api_key.erl | 18 ------------------ src/hex_api_package_owner.erl | 12 ------------ src/hex_api_release.erl | 34 ---------------------------------- src/hex_core.erl | 10 +++++++--- 4 files changed, 7 insertions(+), 67 deletions(-) diff --git a/src/hex_api_key.erl b/src/hex_api_key.erl index b61105c..f16743f 100644 --- a/src/hex_api_key.erl +++ b/src/hex_api_key.erl @@ -76,12 +76,6 @@ get(Config, Name) when is_map(Config) and is_binary(Name) -> %% %% Valid `Resource' values: `<<"read">> | <<"write">>'. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% See {@link hex_api_release:publish/3} for possible 2FA-related error -%% responses and handling. -%% %% Examples: %% %% ``` @@ -110,12 +104,6 @@ add(Config, Name, Permissions) when is_map(Config) and is_binary(Name) and is_li %% @doc %% Deletes an API or repository key. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% See {@link hex_api_release:publish/3} for possible 2FA-related error -%% responses and handling. -%% %% Examples: %% %% ``` @@ -143,12 +131,6 @@ delete(Config, Name) when is_map(Config) and is_binary(Name) -> %% @doc %% Deletes all API and repository keys associated with the account. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% See {@link hex_api_release:publish/3} for possible 2FA-related error -%% responses and handling. -%% %% Examples: %% %% ``` diff --git a/src/hex_api_package_owner.erl b/src/hex_api_package_owner.erl index 4e1644b..0250b65 100644 --- a/src/hex_api_package_owner.erl +++ b/src/hex_api_package_owner.erl @@ -63,12 +63,6 @@ get(Config, PackageName, UsernameOrEmail) when %% @doc %% Adds a packages owner. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% See {@link hex_api_release:publish/3} for possible 2FA-related error -%% responses and handling. -%% %% Examples: %% %% ``` @@ -98,12 +92,6 @@ add(Config, PackageName, UsernameOrEmail, Level, Transfer) when %% @doc %% Deletes a packages owner. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% See {@link hex_api_release:publish/3} for possible 2FA-related error -%% responses and handling. -%% %% Examples: %% %% ``` diff --git a/src/hex_api_release.erl b/src/hex_api_release.erl index fe8988f..df0cb37 100644 --- a/src/hex_api_release.erl +++ b/src/hex_api_release.erl @@ -79,18 +79,6 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). %% Supported query params : %% - replace : boolean %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, two-factor authentication may be required. -%% If required, the server will respond with `{error, otp_required}' and -%% you should retry the request with the TOTP code in the `api_otp' config option. -%% -%% Possible 2FA-related errors: -%% - `{error, otp_required}' - OTP code is required, retry with `api_otp' set -%% - `{error, invalid_totp}' - OTP code was invalid, retry with correct code -%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' - User must enable 2FA -%% - `{ok, {429, _, #{<<"message">> => <<"Too many failed two-factor authentication attempts. Please try again later.">>}}}' - Rate limited -%% %% Examples: %% %% ``` @@ -111,10 +99,6 @@ publish(Config, Tarball) -> publish(Config, Tarball, []). %% <<"url">> => <<"https://hex.pm/api/packages/package/releases/1.0.0">>, %% <<"version">> => <<"1.0.0">> %% }}} -%% -%% %% With 2FA -%% > Config = maps:put(api_otp, <<"123456">>, hex_core:default_config()). -%% > hex_api_release:publish(Config, Tarball). %% ''' %% @end -spec publish(hex_core:config(), binary(), publish_params()) -> hex_api:response(). @@ -143,12 +127,6 @@ publish(Config, Tarball, Params) when %% @doc %% Deletes a package release. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link publish/3} for possible 2FA-related -%% error responses. -%% %% Examples: %% %% ``` @@ -164,12 +142,6 @@ delete(Config, Name, Version) when is_map(Config) and is_binary(Name) and is_bin %% @doc %% Retires a package release. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link publish/3} for possible 2FA-related -%% error responses. -%% %% Examples: %% %% ``` @@ -187,12 +159,6 @@ retire(Config, Name, Version, Params) when %% @doc %% Unretires a package release. %% -%% === Two-Factor Authentication === -%% -%% When using OAuth tokens, you must provide the TOTP code via the -%% `api_otp' config option. See {@link publish/3} for possible 2FA-related -%% error responses. -%% %% Examples: %% %% ``` diff --git a/src/hex_core.erl b/src/hex_core.erl index 768e248..1047fb0 100644 --- a/src/hex_core.erl +++ b/src/hex_core.erl @@ -13,9 +13,13 @@ %% * `api_key' - Authentication key used when accessing the HTTP API. %% %% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication. -%% May be required for write operations when using OAuth tokens. If required, the -%% server will return `{error, otp_required}' and you should retry with this option set. -%% The 6-digit code from your authenticator app. +%% 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.