From 766ae6190b9ae3bd53c7bd7234b582be60e927e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 2 Aug 2026 22:48:19 +0200 Subject: [PATCH] Carry the organizations a session must re-authenticate for An organization can require its members to authenticate through its identity provider, and that authentication expires on a clock the organization sets. When it lapses, the token grant drops the organization's scopes and names them in sso_reauth_required rather than leaving the client to guess why a fetch started 403ing. The name is the whole point: a scope dropped because the member was removed is not named, because authenticating again would not give it back. Only the ones a browser visit would fix are. sso_authorization/2 asks for the URL that does the fixing. It is bound to the session asking, so opening it renews that session rather than starting a new one, and refresh_tokens/1 is how a build tool picks up the scopes afterwards without waiting out an access token that has not expired. The sso_reauth callback is optional and reports the flagged set after every grant, including as an empty list. Which of them the running command actually needs is the build tool's question, not this module's. --- src/hex_api_oauth.erl | 62 ++++++++++++++--- src/hex_cli_auth.erl | 56 ++++++++++++++- test/hex_api_SUITE.erl | 36 ++++++++++ test/hex_cli_auth_SUITE.erl | 120 +++++++++++++++++++++++++++++++++ test/support/hex_http_test.erl | 9 +++ 5 files changed, 272 insertions(+), 11 deletions(-) diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl index e1e0e6ce..59c8c785 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 11a7fdd1..97d98dd8 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 78412f3d..60eeaceb 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 732da0a7..04c6eba8 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 01a88e76..efb2b800 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)}};