Skip to content

Commit 7b4ee45

Browse files
authored
Introduce hex_auth module (#178)
* Add device_auth_flow/4,5 to hex_api_oauth Add a high-level function that handles the complete OAuth device authorization flow: initiating authorization, prompting the user, and polling for token completion. - Add oauth_tokens/0 and device_auth_error/0 types - Handle slow_down, authorization_pending, expired_token, and access_denied responses during polling * Add trusted and oauth_exchange config options Add config options for hex_cli_auth authentication handling: - trusted: whether the repo connection is trusted for auth_key usage - oauth_exchange: whether to exchange api_key/auth_key for OAuth tokens - oauth_exchange_url: optional custom URL for OAuth token exchange * Add hex_cli_auth module for CLI authentication handling Provides callback-based authentication for build tools (rebar3, Mix) with shared auth logic and customizable prompting/persistence. Features: - with_api/4,5 and with_repo/3,4 wrappers for authenticated requests - resolve_api_auth/3 and resolve_repo_auth/2 for credential resolution - Auth resolution order: config, per-repo keys, parent repo, OAuth - OAuth token exchange via client_credentials grant - Automatic token refresh when expired - OTP/TOTP prompt handling with retry logic - Device auth flow for interactive authentication - Support for trusted/untrusted repo connections Includes comprehensive test suite covering: - API and repo auth resolution - Trusted vs untrusted scenarios - OAuth token exchange (new, valid, expired) - OTP prompts and retries - Token refresh on 401 - Device auth flow * Fix body type to represent all JSON value types * Add reauthentication * Allow Token Refresh Error with Repo Read & Optional * Move cli_auth callbacks into config map Instead of passing callbacks as a separate argument to each hex_cli_auth function, they now live under the `cli_auth_callbacks` key in the config map. This reduces function arity and gives callers a single place to configure hex_core.
1 parent ed77bf1 commit 7b4ee45

7 files changed

Lines changed: 1954 additions & 18 deletions

File tree

src/hex_api.erl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
-export_type([response/0]).
1818

1919
-type response() :: {ok, {hex_http:status(), hex_http:headers(), body() | nil}} | {error, term()}.
20-
-type body() :: [body()] | #{binary() => body() | binary()}.
20+
-type body() :: #{binary() => value()} | [#{binary() => value()}].
21+
-type value() :: binary() | boolean() | nil | number() | [value()] | #{binary() => value()}.
2122

2223
%% @private
2324
get(Config, Path) ->

src/hex_api_oauth.erl

Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,30 @@
44
-export([
55
device_authorization/3,
66
device_authorization/4,
7+
device_auth_flow/4,
8+
device_auth_flow/5,
79
poll_device_token/3,
810
refresh_token/3,
911
revoke_token/3,
1012
client_credentials_token/4,
1113
client_credentials_token/5
1214
]).
1315

16+
-export_type([oauth_tokens/0, device_auth_error/0]).
17+
18+
-type oauth_tokens() :: #{
19+
access_token := binary(),
20+
refresh_token => binary() | undefined,
21+
expires_at := integer()
22+
}.
23+
24+
-type device_auth_error() ::
25+
timeout
26+
| {access_denied, Status :: non_neg_integer(), Body :: term()}
27+
| {device_auth_failed, Status :: non_neg_integer(), Body :: term()}
28+
| {poll_failed, Status :: non_neg_integer(), Body :: term()}
29+
| term().
30+
1431
%% @doc
1532
%% Initiates the OAuth device authorization flow.
1633
%%
@@ -26,7 +43,7 @@ device_authorization(Config, ClientId, Scope) ->
2643
%% Returns device code, user code, and verification URIs for user authentication.
2744
%%
2845
%% Options:
29-
%% * `name' - A name to identify the token (e.g., hostname of the device)
46+
%% * `name' - A name to identify the token (defaults to the machine's hostname)
3047
%%
3148
%% Examples:
3249
%%
@@ -49,17 +66,141 @@ device_authorization(Config, ClientId, Scope) ->
4966
hex_api:response().
5067
device_authorization(Config, ClientId, Scope, Opts) ->
5168
Path = <<"oauth/device_authorization">>,
52-
Params0 = #{
53-
<<"client_id">> => ClientId,
54-
<<"scope">> => Scope
55-
},
56-
Params =
69+
Name =
5770
case proplists:get_value(name, Opts) of
58-
undefined -> Params0;
59-
Name -> Params0#{<<"name">> => Name}
71+
undefined -> get_hostname();
72+
N -> N
6073
end,
74+
Params = #{
75+
<<"client_id">> => ClientId,
76+
<<"scope">> => Scope,
77+
<<"name">> => Name
78+
},
6179
hex_api:post(Config, Path, Params).
6280

81+
%% @doc
82+
%% Runs the complete OAuth device authorization flow.
83+
%%
84+
%% @see device_auth_flow/5
85+
%% @end
86+
-spec device_auth_flow(
87+
hex_core:config(),
88+
ClientId :: binary(),
89+
Scope :: binary(),
90+
PromptUser :: fun((VerificationUri :: binary(), UserCode :: binary()) -> ok)
91+
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
92+
device_auth_flow(Config, ClientId, Scope, PromptUser) ->
93+
device_auth_flow(Config, ClientId, Scope, PromptUser, []).
94+
95+
%% @doc
96+
%% Runs the complete OAuth device authorization flow with options.
97+
%%
98+
%% This function handles the entire device authorization flow:
99+
%% 1. Requests a device code from the server
100+
%% 2. Calls `PromptUser' callback with the verification URI and user code
101+
%% 3. Optionally opens the browser for the user (when `open_browser' is true)
102+
%% 4. Polls the token endpoint until authorization completes or times out
103+
%%
104+
%% The `PromptUser' callback is responsible for displaying the verification URI
105+
%% and user code to the user (e.g., printing to console).
106+
%%
107+
%% Options:
108+
%% * `name' - A name to identify the token (defaults to the machine's hostname)
109+
%% * `open_browser' - When `true', automatically opens the browser
110+
%% to the verification URI. When `false' (default), only the callback is invoked.
111+
%%
112+
%% Returns:
113+
%% - `{ok, Tokens}' - Authorization successful, returns access token and optional refresh token
114+
%% - `{error, timeout}' - Device code expired before user completed authorization
115+
%% - `{error, {access_denied, Status, Body}}' - User denied the authorization request
116+
%% - `{error, {device_auth_failed, Status, Body}}' - Initial device authorization request failed
117+
%% - `{error, {poll_failed, Status, Body}}' - Unexpected error during polling
118+
%%
119+
%% Examples:
120+
%%
121+
%% ```
122+
%% 1> Config = hex_core:default_config().
123+
%% 2> PromptUser = fun(Uri, Code) ->
124+
%% io:format("Visit ~s and enter code: ~s~n", [Uri, Code])
125+
%% end.
126+
%% 3> hex_api_oauth:device_auth_flow(Config, <<"cli">>, <<"api:write">>, PromptUser).
127+
%% {ok, #{
128+
%% access_token => <<"...">>,
129+
%% refresh_token => <<"...">>,
130+
%% expires_at => 1234567890
131+
%% }}
132+
%% '''
133+
%% @end
134+
-spec device_auth_flow(
135+
hex_core:config(),
136+
ClientId :: binary(),
137+
Scope :: binary(),
138+
PromptUser :: fun((VerificationUri :: binary(), UserCode :: binary()) -> ok),
139+
proplists:proplist()
140+
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
141+
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
142+
case device_authorization(Config, ClientId, Scope, Opts) of
143+
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
144+
#{
145+
<<"device_code">> := DeviceCode,
146+
<<"user_code">> := UserCode,
147+
<<"verification_uri_complete">> := VerificationUri,
148+
<<"expires_in">> := ExpiresIn,
149+
<<"interval">> := IntervalSeconds
150+
} = DeviceResponse,
151+
ok = PromptUser(VerificationUri, UserCode),
152+
OpenBrowser = proplists:get_value(open_browser, Opts, false),
153+
case OpenBrowser of
154+
true -> open_browser(VerificationUri);
155+
false -> ok
156+
end,
157+
ExpiresAt = erlang:system_time(second) + ExpiresIn,
158+
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
159+
{ok, {Status, _, Body}} ->
160+
{error, {device_auth_failed, Status, Body}};
161+
{error, Reason} ->
162+
{error, Reason}
163+
end.
164+
165+
%% @private
166+
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
167+
Now = erlang:system_time(second),
168+
case Now >= ExpiresAt of
169+
true ->
170+
{error, timeout};
171+
false ->
172+
timer:sleep(IntervalSeconds * 1000),
173+
case poll_device_token(Config, ClientId, DeviceCode) of
174+
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
175+
#{
176+
<<"access_token">> := AccessToken,
177+
<<"expires_in">> := ExpiresIn
178+
} = TokenResponse,
179+
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
180+
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
181+
{ok, #{
182+
access_token => AccessToken,
183+
refresh_token => RefreshToken,
184+
expires_at => TokenExpiresAt
185+
}};
186+
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
187+
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
188+
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
189+
%% Increase polling interval as requested by server
190+
poll_for_token_loop(
191+
Config, ClientId, DeviceCode, IntervalSeconds + 5, ExpiresAt
192+
);
193+
{ok, {400, _, #{<<"error">> := <<"expired_token">>}}} ->
194+
{error, timeout};
195+
{ok, {Status, _, #{<<"error">> := <<"access_denied">>} = Body}} ->
196+
{error, {access_denied, Status, Body}};
197+
{ok, {Status, _, Body}} ->
198+
{error, {poll_failed, Status, Body}};
199+
{error, Reason} ->
200+
{error, Reason}
201+
end
202+
end.
203+
63204
%% @doc
64205
%% Polls the OAuth token endpoint for device authorization completion.
65206
%%
@@ -199,3 +340,48 @@ revoke_token(Config, ClientId, Token) ->
199340
<<"client_id">> => ClientId
200341
},
201342
hex_api:post(Config, Path, Params).
343+
344+
%%====================================================================
345+
%% Internal functions
346+
%%====================================================================
347+
348+
%% @private
349+
%% Open a URL in the default browser.
350+
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
351+
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
352+
open_browser(Url) when is_binary(Url) ->
353+
ok = ensure_valid_http_url(Url),
354+
UrlStr = binary_to_list(Url),
355+
{Cmd, Args} =
356+
case os:type() of
357+
{unix, darwin} ->
358+
{"open", [UrlStr]};
359+
{unix, _} ->
360+
{"xdg-open", [UrlStr]};
361+
{win32, _} ->
362+
{"cmd", ["/c", "start", "", UrlStr]}
363+
end,
364+
case os:find_executable(Cmd) of
365+
false ->
366+
{error, browser_not_found};
367+
Executable ->
368+
open_port({spawn_executable, Executable}, [{args, Args}]),
369+
ok
370+
end.
371+
372+
%% @private
373+
%% Validates that a URL uses http:// or https:// scheme.
374+
-spec ensure_valid_http_url(binary()) -> ok.
375+
ensure_valid_http_url(Url) when is_binary(Url) ->
376+
case uri_string:parse(Url) of
377+
#{scheme := <<"https">>} -> ok;
378+
#{scheme := <<"http">>} -> ok;
379+
_ -> throw({invalid_url, Url})
380+
end.
381+
382+
%% @private
383+
%% Get the hostname of the current machine.
384+
-spec get_hostname() -> binary().
385+
get_hostname() ->
386+
{ok, Hostname} = inet:gethostname(),
387+
list_to_binary(Hostname).

0 commit comments

Comments
 (0)