Skip to content

Commit 6848725

Browse files
authored
Support 2FA requirements from API server (#160)
1 parent a042bb5 commit 6848725

File tree

7 files changed

+145
-18
lines changed

7 files changed

+145
-18
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ Publish package tarball:
9090
{ok, {200, _Headers, _Body} = hex_api_package:publish(Config, Tarball).
9191
```
9292

93+
### Two-Factor Authentication
94+
95+
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:
96+
97+
```erlang
98+
%% First attempt without OTP
99+
case hex_api_release:publish(Config, Tarball) of
100+
{error, otp_required} ->
101+
%% Retry with TOTP code
102+
ConfigWithOTP = Config#{api_otp := <<"123456">>},
103+
hex_api_release:publish(ConfigWithOTP, Tarball);
104+
Result ->
105+
Result
106+
end.
107+
```
108+
109+
API keys don't require TOTP validation.
110+
93111
### Package tarballs
94112
95113
Unpack package tarball:

src/hex_api.erl

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,13 @@ request(Config, Method, Path, Body) when is_binary(Path) and is_map(Config) ->
101101
case hex_http:request(Config, Method, build_url(Path, Config), ReqHeaders2, Body) of
102102
{ok, {Status, RespHeaders, RespBody}} ->
103103
ContentType = maps:get(<<"content-type">>, RespHeaders, <<"">>),
104-
case binary:match(ContentType, ?ERL_CONTENT_TYPE) of
104+
Response = case binary:match(ContentType, ?ERL_CONTENT_TYPE) of
105105
{_, _} ->
106106
{ok, {Status, RespHeaders, binary_to_term(RespBody)}};
107107
nomatch ->
108108
{ok, {Status, RespHeaders, nil}}
109-
end;
109+
end,
110+
detect_otp_error(Response);
110111
Other ->
111112
Other
112113
end.
@@ -133,6 +134,8 @@ make_headers(Config) ->
133134
%% @private
134135
set_header(api_key, Token, Headers) when is_binary(Token) ->
135136
maps:put(<<"authorization">>, Token, Headers);
137+
set_header(api_otp, OTP, Headers) when is_binary(OTP) ->
138+
maps:put(<<"x-hex-otp">>, OTP, Headers);
136139
set_header(_, _, Headers) ->
137140
Headers.
138141

@@ -161,3 +164,17 @@ to_list(A) when is_atom(A) -> atom_to_list(A);
161164
to_list(B) when is_binary(B) -> unicode:characters_to_list(B);
162165
to_list(I) when is_integer(I) -> integer_to_list(I);
163166
to_list(Str) -> unicode:characters_to_list(Str).
167+
168+
%% TODO: not needed after exdoc is fixed
169+
%% @private
170+
detect_otp_error({ok, {401, Headers, Body}}) ->
171+
case maps:get(<<"www-authenticate">>, Headers, nil) of
172+
<<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> ->
173+
{error, otp_required};
174+
<<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> ->
175+
{error, invalid_totp};
176+
_ ->
177+
{ok, {401, Headers, Body}}
178+
end;
179+
detect_otp_error(Response) ->
180+
Response.

src/hex_api_release.erl

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,24 @@ publish(Config, Tarball) -> publish(Config, Tarball, []).
105105
publish(Config, Tarball, Params) when
106106
is_map(Config) andalso is_binary(Tarball) andalso is_list(Params)
107107
->
108-
QueryString = hex_api:encode_query_string([
109-
{replace, proplists:get_value(replace, Params, false)}
110-
]),
111-
Path = hex_api:join_path_segments(hex_api:build_repository_path(Config, ["publish"])),
112-
PathWithQuery = <<Path/binary, "?", QueryString/binary>>,
113-
TarballContentType = "application/octet-stream",
114-
Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config),
115-
Body = {TarballContentType, Tarball},
116-
hex_api:post(Config2, PathWithQuery, Body).
108+
case hex_tarball:unpack(Tarball, memory) of
109+
{ok, #{metadata := Metadata}} ->
110+
PackageName = maps:get(<<"name">>, Metadata),
111+
QueryString = hex_api:encode_query_string([
112+
{replace, proplists:get_value(replace, Params, false)}
113+
]),
114+
Path = hex_api:join_path_segments(
115+
hex_api:build_repository_path(Config, ["packages", PackageName, "releases"])
116+
),
117+
PathWithQuery = <<Path/binary, "?", QueryString/binary>>,
118+
TarballContentType = "application/octet-stream",
119+
Config2 = put_header(<<"content-length">>, integer_to_binary(byte_size(Tarball)), Config),
120+
Config3 = maybe_put_expect_header(Config2),
121+
Body = {TarballContentType, Tarball},
122+
hex_api:post(Config3, PathWithQuery, Body);
123+
{error, Reason} ->
124+
{error, {tarball, Reason}}
125+
end.
117126

118127
%% @doc
119128
%% Deletes a package release.
@@ -171,3 +180,10 @@ put_header(Name, Value, Config) ->
171180
Headers = maps:get(http_headers, Config, #{}),
172181
Headers2 = maps:put(Name, Value, Headers),
173182
maps:put(http_headers, Headers2, Config).
183+
184+
%% @private
185+
maybe_put_expect_header(Config) ->
186+
case maps:get(send_100_continue, Config, true) of
187+
true -> put_header(<<"expect">>, <<"100-continue">>, Config);
188+
false -> Config
189+
end.

src/hex_core.erl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
%%
1313
%% * `api_key' - Authentication key used when accessing the HTTP API.
1414
%%
15+
%% * `api_otp' - TOTP (Time-based One-Time Password) code for two-factor authentication.
16+
%% When using OAuth tokens, write operations require 2FA if the user has it enabled.
17+
%% If required, the server returns one of:
18+
%% - `{error, otp_required}' - Retry the request with a 6-digit TOTP code in this option
19+
%% - `{error, invalid_totp}' - The provided TOTP code was incorrect, retry with correct code
20+
%% - `{ok, {403, _, #{<<"message">> => <<"Two-factor authentication must be enabled for API write access">>}}}' - User must enable 2FA first
21+
%% - `{ok, {429, _, _}}' - Too many failed TOTP attempts, rate limited
22+
%% API keys do not require TOTP validation.
23+
%%
1524
%% * `api_organization' - Name of the organization endpoint in the API, this should
1625
%% for example be set when accessing key for a specific organization.
1726
%%
@@ -47,6 +56,10 @@
4756
%% * `repo_verify_origin' - If `true' will verify the repository signature origin,
4857
%% requires protobuf messages as of hex_core v0.4.0 (default: `true').
4958
%%
59+
%% * `send_100_continue' - If `true' will send `Expect: 100-continue' header for
60+
%% publish operations. This allows the server to validate authentication and
61+
%% authorization before the client sends the request body (default: `true').
62+
%%
5063
%% * `tarball_max_size' - Maximum size of package tarball, defaults to
5164
%% `16_777_216' (16 MiB). Set to `infinity' to not enforce the limit.
5265
%%
@@ -79,6 +92,7 @@
7992

8093
-type config() :: #{
8194
api_key => binary() | undefined,
95+
api_otp => binary() | undefined,
8296
api_organization => binary() | undefined,
8397
api_repository => binary() | undefined,
8498
api_url => binary(),
@@ -93,6 +107,7 @@
93107
repo_organization => binary() | undefined,
94108
repo_verify => boolean(),
95109
repo_verify_origin => boolean(),
110+
send_100_continue => boolean(),
96111
tarball_max_size => pos_integer() | infinity,
97112
tarball_max_uncompressed_size => pos_integer() | infinity,
98113
docs_tarball_max_size => pos_integer() | infinity,
@@ -103,6 +118,7 @@
103118
default_config() ->
104119
#{
105120
api_key => undefined,
121+
api_otp => undefined,
106122
api_organization => undefined,
107123
api_repository => undefined,
108124
api_url => <<"https://hex.pm/api">>,
@@ -117,6 +133,7 @@ default_config() ->
117133
repo_organization => undefined,
118134
repo_verify => true,
119135
repo_verify_origin => true,
136+
send_100_continue => true,
120137
tarball_max_size => 16 * 1024 * 1024,
121138
tarball_max_uncompressed_size => 128 * 1024 * 1024,
122139
docs_tarball_max_size => 16 * 1024 * 1024,

src/hex_repo.erl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,5 +254,7 @@ set_header(http_etag, ETag, Headers) when is_binary(ETag) ->
254254
maps:put(<<"if-none-match">>, ETag, Headers);
255255
set_header(repo_key, Token, Headers) when is_binary(Token) ->
256256
maps:put(<<"authorization">>, Token, Headers);
257+
set_header(api_otp, OTP, Headers) when is_binary(OTP) ->
258+
maps:put(<<"x-hex-otp">>, OTP, Headers);
257259
set_header(_, _, Headers) ->
258260
Headers.

test/hex_api_SUITE.erl

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ suite() ->
2020

2121
all() ->
2222
[package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test,
23-
oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test].
23+
oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test,
24+
publish_with_expect_header_test, publish_without_expect_header_test].
2425

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

4748
publish_test(_Config) ->
48-
{ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>),
49+
Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>},
50+
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
51+
{ok, {200, _, Release}} = hex_api_release:publish(?CONFIG, Tarball),
4952
#{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release,
5053
#{
5154
<<"decimal">> := #{
@@ -55,7 +58,9 @@ publish_test(_Config) ->
5558
ok.
5659

5760
replace_test(_Config) ->
58-
{ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, <<"dummy_tarball">>, [
61+
Metadata = #{<<"name">> => <<"ecto">>, <<"version">> => <<"1.0.0">>},
62+
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
63+
{ok, {201, _, Release}} = hex_api_release:publish(?CONFIG, Tarball, [
5964
{replace, true}
6065
]),
6166
#{<<"version">> := <<"1.0.0">>, <<"requirements">> := Requirements} = Release,
@@ -168,3 +173,25 @@ oauth_revoke_test(_Config) ->
168173
NonExistentToken = <<"non_existent_token">>,
169174
{ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken),
170175
ok.
176+
177+
publish_with_expect_header_test(_Config) ->
178+
% Test that send_100_continue => true includes Expect: 100-continue header
179+
Metadata = #{<<"name">> => <<"expect_test">>, <<"version">> => <<"1.0.0">>},
180+
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
181+
182+
% Default config has send_100_continue => true
183+
Config = ?CONFIG,
184+
{ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball),
185+
#{<<"version">> := <<"1.0.0">>} = Release,
186+
ok.
187+
188+
publish_without_expect_header_test(_Config) ->
189+
% Test that send_100_continue => false does not include Expect header
190+
Metadata = #{<<"name">> => <<"no_expect_test">>, <<"version">> => <<"1.0.0">>},
191+
{ok, #{tarball := Tarball}} = hex_tarball:create(Metadata, []),
192+
193+
% Explicitly disable send_100_continue
194+
Config = maps:put(send_100_continue, false, ?CONFIG),
195+
{ok, {200, _, Release}} = hex_api_release:publish(Config, Tarball),
196+
#{<<"version">> := <<"1.0.0">>} = Release,
197+
ok.

test/support/hex_http_test.erl

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,39 @@ fixture(get, <<?TEST_API_URL, "/packages/ecto/releases/1.0.0">>, _, _) ->
158158
},
159159
{ok, {200, api_headers(), term_to_binary(Payload)}};
160160

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

163-
fixture(get, <<?TEST_API_URL, "/publish">>, _, _) ->
163+
fixture(post, <<?TEST_API_URL, "/packages/expect_test/releases?replace=false">>, Headers, _) ->
164+
% Verify that Expect: 100-continue header is present
165+
case maps:get(<<"expect">>, Headers, undefined) of
166+
<<"100-continue">> ->
167+
Payload = #{
168+
<<"version">> => <<"1.0.0">>,
169+
<<"requirements">> => #{}
170+
},
171+
{ok, {200, api_headers(), term_to_binary(Payload)}};
172+
_ ->
173+
error({expect_header_missing, Headers})
174+
end;
175+
176+
%% /packages/:name/releases - test expect header absence
177+
178+
fixture(post, <<?TEST_API_URL, "/packages/no_expect_test/releases?replace=false">>, Headers, _) ->
179+
% Verify that Expect header is NOT present
180+
case maps:get(<<"expect">>, Headers, undefined) of
181+
undefined ->
182+
Payload = #{
183+
<<"version">> => <<"1.0.0">>,
184+
<<"requirements">> => #{}
185+
},
186+
{ok, {200, api_headers(), term_to_binary(Payload)}};
187+
Value ->
188+
error({expect_header_present, Value})
189+
end;
190+
191+
%% /packages/:name/releases
192+
193+
fixture(post, <<?TEST_API_URL, "/packages/ecto/releases?replace=false">>, _, _) ->
164194
Payload = #{
165195
<<"version">> => <<"1.0.0">>,
166196
<<"requirements">> => #{
@@ -173,9 +203,9 @@ fixture(get, <<?TEST_API_URL, "/publish">>, _, _) ->
173203
},
174204
{ok, {200, api_headers(), term_to_binary(Payload)}};
175205

176-
%% /publish?replace=true
206+
%% /packages/:name/releases?replace=true
177207

178-
fixture(post, <<?TEST_API_URL, "/publish?replace=true">>, _, _) ->
208+
fixture(post, <<?TEST_API_URL, "/packages/ecto/releases?replace=true">>, _, _) ->
179209
Payload = #{
180210
<<"version">> => <<"1.0.0">>,
181211
<<"requirements">> => #{

0 commit comments

Comments
 (0)