diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl index e1e0e6c..59c8c78 100644 --- a/src/hex_api_oauth.erl +++ b/src/hex_api_oauth.erl @@ -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 @@ -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() :: @@ -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); @@ -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. %% @@ -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), @@ -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. diff --git a/src/hex_cli_auth.erl b/src/hex_cli_auth.erl index 11a7fdd..97d98dd 100644 --- a/src/hex_cli_auth.erl +++ b/src/hex_cli_auth.erl @@ -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()), @@ -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([ @@ -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()) @@ -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 %%==================================================================== @@ -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, @@ -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}}; @@ -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. diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index 78412f3..60eeace 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -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, @@ -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">>, diff --git a/test/hex_cli_auth_SUITE.erl b/test/hex_cli_auth_SUITE.erl index 732da0a..04c6eba 100644 --- a/test/hex_cli_auth_SUITE.erl +++ b/test/hex_cli_auth_SUITE.erl @@ -50,6 +50,12 @@ all() -> with_api_otp_cancelled_test, with_api_otp_max_retries_test, + %% sso re-authorization + sso_reauth_reported_on_refresh_test, + sso_reauth_reported_empty_test, + refresh_tokens_forces_a_refresh_test, + refresh_tokens_without_credentials_test, + %% with_api tests - token refresh on 401 with_api_token_expired_refresh_test, @@ -1171,6 +1177,118 @@ device_auth_concurrent_serialized_reuses_login_test(_Config) -> %% Helper Functions %%==================================================================== +sso_reauth_reported_on_refresh_test(_Config) -> + %% The organizations the server flags on a refresh reach the build tool. + Now = erlang:system_time(second), + Self = self(), + Config = config_with_callbacks(#{ + oauth_tokens => + {ok, #{ + access_token => <<"expired_token">>, + refresh_token => <<"refresh_token">>, + expires_at => Now - 100 + }}, + sso_reauth => fun(Organizations) -> + Self ! {sso_reauth, Organizations}, + ok + end + }), + + queue_refresh_response(#{<<"sso_reauth_required">> => [<<"acme">>]}), + + {ok, _ApiKey, _AuthContext} = hex_cli_auth:resolve_api_auth(read, Config), + + receive + {sso_reauth, Organizations} -> ?assertEqual([<<"acme">>], Organizations) + after 100 -> + error(sso_reauth_not_called) + end, + ok. + +sso_reauth_reported_empty_test(_Config) -> + %% A server that says nothing means nothing is lapsed, and the build tool + %% is told so rather than left holding a stale set. + Now = erlang:system_time(second), + Self = self(), + Config = config_with_callbacks(#{ + oauth_tokens => + {ok, #{ + access_token => <<"expired_token">>, + refresh_token => <<"refresh_token">>, + expires_at => Now - 100 + }}, + sso_reauth => fun(Organizations) -> + Self ! {sso_reauth, Organizations}, + ok + end + }), + + {ok, _ApiKey, _AuthContext} = hex_cli_auth:resolve_api_auth(read, Config), + + receive + {sso_reauth, Organizations} -> ?assertEqual([], Organizations) + after 100 -> + error(sso_reauth_not_called) + end, + ok. + +refresh_tokens_forces_a_refresh_test(_Config) -> + %% A token that has not expired is still refreshed: what it carries can + %% change without its lifetime running out. + Now = erlang:system_time(second), + Self = self(), + Config = config_with_callbacks(#{ + oauth_tokens => + {ok, #{ + access_token => <<"valid_token">>, + refresh_token => <<"refresh_token">>, + expires_at => Now + 3600 + }}, + persist_oauth_tokens => fun(Scope, Access, Refresh, Expires) -> + Self ! {persisted, Scope, Access, Refresh, Expires}, + ok + end + }), + + queue_refresh_response(#{<<"access_token">> => <<"renewed_token">>}), + + ?assertEqual(ok, hex_cli_auth:refresh_tokens(Config)), + + receive + {persisted, global, Access, _Refresh, _Expires} -> + ?assertEqual(<<"renewed_token">>, Access) + after 100 -> + error(token_not_persisted) + end, + ok. + +refresh_tokens_without_credentials_test(_Config) -> + Config = config_with_callbacks(#{}), + + ?assertEqual( + {error, {auth_error, no_credentials}}, + hex_cli_auth:refresh_tokens(Config) + ), + ok. + +%% @private +%% Plants the next refresh response the test HTTP adapter will hand back, +%% merged over a working one so a test only states what it cares about. +queue_refresh_response(Overrides) -> + Payload = maps:merge( + #{ + <<"access_token">> => <<"new_access_token">>, + <<"refresh_token">> => <<"new_refresh_token">>, + <<"token_type">> => <<"Bearer">>, + <<"expires_in">> => 3600 + }, + Overrides + ), + Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>}, + self() ! + {hex_http_test, oauth_refresh_response, {ok, {200, Headers, term_to_binary(Payload)}}}, + ok. + config_with_callbacks(Opts) -> ?CONFIG#{cli_auth_callbacks => make_callbacks(Opts)}. @@ -1180,6 +1298,7 @@ make_callbacks(Opts) -> ShouldAuthenticate = maps:get(should_authenticate, Opts, fun(_) -> false end), PersistFn = maps:get(persist_oauth_tokens, Opts, fun(_, _, _, _) -> ok end), ClearFn = maps:get(clear_oauth_tokens, Opts, fun() -> ok end), + SsoReauthFn = maps:get(sso_reauth, Opts, fun(_Organizations) -> ok end), DefaultGetOAuthTokens = fun() -> maps:get(oauth_tokens, Opts, error) end, GetOAuthTokensFn = maps:get(get_oauth_tokens, Opts, DefaultGetOAuthTokens), @@ -1188,6 +1307,7 @@ make_callbacks(Opts) -> get_oauth_tokens => GetOAuthTokensFn, persist_oauth_tokens => PersistFn, clear_oauth_tokens => ClearFn, + sso_reauth => SsoReauthFn, prompt_otp => PromptOtp, should_authenticate => ShouldAuthenticate, get_client_id => fun() -> <<"test_client">> end diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 01a88e7..efb2b80 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -405,6 +405,15 @@ fixture(post, <>, _, {_, Body}) -> {ok, {400, api_headers(), term_to_binary(ErrorPayload)}} end; +fixture(post, <>, _, {_, Body}) -> + #{<<"organizations">> := Organizations} = binary_to_term(Body), + Joined = iolist_to_binary(lists:join(<<"-">>, Organizations)), + Payload = #{ + <<"verification_uri">> => <<"https://hex.pm/sso/authorize/", Joined/binary>>, + <<"expires_in">> => 600 + }, + {ok, {201, api_headers(), term_to_binary(Payload)}}; + fixture(post, <>, _, _) -> % OAuth revoke always returns 200 OK per RFC 7009 {ok, {200, api_headers(), term_to_binary(nil)}};