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
62 changes: 53 additions & 9 deletions src/hex_api_oauth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
open_browser/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
Expand All @@ -18,7 +20,11 @@
-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand Down Expand Up @@ -181,7 +187,8 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
expires_at => TokenExpiresAt,
sso_reauth_required => sso_reauth_required(TokenResponse)
}};
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
Expand Down Expand Up @@ -260,6 +267,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand Down Expand Up @@ -341,13 +372,13 @@ revoke_token(Config, ClientId, Token) ->
},
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).
%% @doc
%% Opens a URL in the default browser.
%%
%% Uses the platform's opener: `open' on macOS, `xdg-open' on Linux, `start'
%% on Windows. Returns `{error, browser_not_found}' when none of them exists,
%% which is the ordinary case on a headless machine.
%% @end
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
Expand All @@ -369,6 +400,19 @@ open_browser(Url) when is_binary(Url) ->
ok
end.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Older servers do not send the field at all, which means nothing is lapsed.
-spec sso_reauth_required(map()) -> [binary()].
sso_reauth_required(TokenResponse) ->
case maps:get(<<"sso_reauth_required">>, TokenResponse, []) of
Organizations when is_list(Organizations) -> Organizations;
_Other -> []
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
Expand Down
56 changes: 54 additions & 2 deletions src/hex_cli_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
%% %% holding the token-refresh lock.
%% clear_oauth_tokens => fun(() -> ok),
%%
%% %% Report the organizations the server says this session has to
%% %% authenticate against their identity provider for (optional). Called
%% %% after every token grant, with the empty list when there are none, so
%% %% the build tool always holds the current set. It is not told which of
%% %% them the running command needs; deciding that is the build tool's job.
%% sso_reauth => fun(([binary()]) -> ok),
%%
%% %% User interaction
%% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
%% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()),
Expand Down Expand Up @@ -87,7 +94,8 @@
with_repo/2,
with_repo/3,
resolve_api_auth/2,
resolve_repo_auth/1
resolve_repo_auth/1,
refresh_tokens/1
]).

-export_type([
Expand Down Expand Up @@ -120,6 +128,7 @@
) -> ok
),
clear_oauth_tokens => fun(() -> ok),
sso_reauth => fun((Organizations :: [binary()]) -> ok),
prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled),
should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()),
get_client_id := fun(() -> binary())
Expand Down Expand Up @@ -394,6 +403,32 @@ execute_optional_with_retry(BaseConfig, Fun, Opts) ->
Other
end.

%% @doc
%% Refreshes the stored global OAuth token now, whether or not it has expired.
%%
%% What a token carries can change without it expiring: authenticating a
%% session against an organization's identity provider grants scopes the
%% current access token was minted without. This is how a build tool picks
%% those up rather than waiting out the access token.
-spec refresh_tokens(hex_core:config()) -> ok | {error, auth_error()}.
refresh_tokens(Config) ->
global:trans(
{{?MODULE, token_refresh}, self()},
fun() ->
case call_callback(Config, get_oauth_tokens, []) of
{ok, Tokens} ->
case maybe_refresh_token_with_context(Config, Tokens) of
{ok, _BearerToken, _AuthContext} -> ok;
{error, _Reason} = Error -> Error
end;
error ->
{error, {auth_error, no_credentials}}
end
end,
[node()],
infinity
).

%%====================================================================
%% Internal functions - Device Auth
%%====================================================================
Expand All @@ -412,10 +447,13 @@ device_auth(Config, Scope, Opts) ->
end,
FlowOpts = [{open_browser, OpenBrowser}],
case hex_api_oauth:device_auth_flow(Config, ClientId, Scope, PromptUser, FlowOpts) of
{ok, #{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt}} ->
{ok,
#{access_token := AccessToken, refresh_token := RefreshToken, expires_at := ExpiresAt} =
Tokens} ->
ok = call_callback(Config, persist_oauth_tokens, [
global, AccessToken, RefreshToken, ExpiresAt
]),
report_sso_reauth(Config, Tokens),
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
Expand Down Expand Up @@ -648,6 +686,7 @@ maybe_refresh_token_with_context(Config, #{refresh_token := RefreshToken}) when
ok = call_callback(Config, persist_oauth_tokens, [
global, NewAccessToken, NewRefreshToken, ExpiresAt
]),
report_sso_reauth(Config, TokenResponse),
BearerToken = <<"Bearer ", NewAccessToken/binary>>,
HasRefreshToken = is_binary(NewRefreshToken),
{ok, BearerToken, #{source => oauth, has_refresh_token => HasRefreshToken}};
Expand Down Expand Up @@ -780,6 +819,19 @@ call_callback(Config, Name, Args) ->
Fun = maps:get(Name, Callbacks),
erlang:apply(Fun, Args).

%% @private
%% Hands the build tool the organizations this session has to authenticate for.
%% Always called after a grant, including with the empty list, so a set that
%% has been resolved does not linger.
report_sso_reauth(Config, #{sso_reauth_required := Organizations}) when is_list(Organizations) ->
maybe_call_callback(Config, sso_reauth, [Organizations]);
report_sso_reauth(Config, #{<<"sso_reauth_required">> := Organizations}) when
is_list(Organizations)
->
maybe_call_callback(Config, sso_reauth, [Organizations]);
report_sso_reauth(Config, _Tokens) ->
maybe_call_callback(Config, sso_reauth, [[]]).

%% @private
%% Like call_callback/3 but for optional callbacks: returns ok without doing
%% anything when the callback is not provided.
Expand Down
36 changes: 36 additions & 0 deletions test/hex_api_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ all() ->
oauth_device_auth_flow_denied_test,
oauth_device_auth_flow_timeout_test,
oauth_refresh_token_test,
oauth_sso_authorization_test,
oauth_device_auth_flow_sso_reauth_test,
oauth_revoke_test,
oauth_client_credentials_test,
publish_with_expect_header_test,
Expand Down Expand Up @@ -242,6 +244,40 @@ oauth_refresh_token_test(_Config) ->
?assert(is_integer(ExpiresIn)),
ok.

oauth_sso_authorization_test(_Config) ->
{ok, {201, _, Response}} = hex_api_oauth:sso_authorization(?CONFIG, [<<"acme">>]),
#{
<<"verification_uri">> := VerificationUri,
<<"expires_in">> := ExpiresIn
} = Response,
?assertEqual(<<"https://hex.pm/sso/authorize/acme">>, VerificationUri),
?assert(is_integer(ExpiresIn)),
ok.

oauth_device_auth_flow_sso_reauth_test(_Config) ->
% The organizations a token was minted without reach the caller
ClientId = <<"cli">>,
Scope = <<"repositories">>,
Self = self(),
PromptUser = fun(_VerificationUri, _UserCode) -> ok end,

SuccessPayload = #{
<<"access_token">> => <<"test_access_token">>,
<<"refresh_token">> => <<"test_refresh_token">>,
<<"token_type">> => <<"Bearer">>,
<<"expires_in">> => 3600,
<<"sso_reauth_required">> => [<<"acme">>]
},
Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>},
Self !
{hex_http_test, oauth_device_response,
{ok, {200, Headers, term_to_binary(SuccessPayload)}}},

{ok, Tokens} = hex_api_oauth:device_auth_flow(?CONFIG, ClientId, Scope, PromptUser),

?assertEqual([<<"acme">>], maps:get(sso_reauth_required, Tokens)),
ok.

oauth_revoke_test(_Config) ->
% Test token revocation
ClientId = <<"cli">>,
Expand Down
Loading
Loading