Skip to content
Draft
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
3 changes: 2 additions & 1 deletion src/hex_api.erl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
-export_type([response/0]).

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

%% @private
get(Config, Path) ->
Expand Down
198 changes: 190 additions & 8 deletions src/hex_api_oauth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,30 @@
-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,
client_credentials_token/4,
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()}
| term().

%% @doc
%% Initiates the OAuth device authorization flow.
%%
Expand All @@ -26,7 +43,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:
%%
Expand All @@ -49,17 +66,141 @@ device_authorization(Config, ClientId, Scope) ->
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();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just double-checking, why do we need to send user's hostname?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

N -> N
end,
Params = #{
<<"client_id">> => ClientId,
<<"scope">> => Scope,
<<"name">> => Name
},
hex_api:post(Config, Path, Params).

%% @doc
%% Runs the complete OAuth device authorization flow.
%%
%% @see device_auth_flow/5
%% @end
-spec device_auth_flow(
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 = hex_core:default_config().
%% 2> PromptUser = fun(Uri, Code) ->
%% io:format("Visit ~s and enter code: ~s~n", [Uri, Code])
%% end.
%% 3> hex_api_oauth:device_auth_flow(Config, <<"cli">>, <<"api:write">>, PromptUser).
%% {ok, #{
%% access_token => <<"...">>,
%% refresh_token => <<"...">>,
%% expires_at => 1234567890
%% }}
%% '''
%% @end
-spec device_auth_flow(
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.
%%
Expand Down Expand Up @@ -199,3 +340,44 @@ revoke_token(Config, ClientId, Token) ->
<<"client_id">> => ClientId
},
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() ->
{ok, Hostname} = inet:gethostname(),
list_to_binary(Hostname).
Loading
Loading