diff --git a/src/hex_cli_auth.erl b/src/hex_cli_auth.erl index 6f67bca..ec9f28d 100644 --- a/src/hex_cli_auth.erl +++ b/src/hex_cli_auth.erl @@ -43,6 +43,20 @@ %% %% that is the build tool's job. %% organization_reauth => fun(([map()]) -> ok), %% +%% %% Outcome of the last Workload Identity exchange for a repository +%% %% (optional, both or neither). Hex.pm organization repositories are +%% %% only fetched with a workload identity when the build tool keeps the +%% %% outcome, so concurrent and later requests reuse a token, or the +%% %% failure, instead of each exchanging a new OIDC token. Keep it in +%% %% memory for the rest of the run, the token expires in minutes. +%% get_workload_identity_token => fun((RepoName :: binary()) -> +%% {ok, #{access_token := binary(), expires_at := integer()}} +%% | {error, workload_identity_error()} +%% | error), +%% persist_workload_identity_token => fun((RepoName :: binary(), +%% {ok, #{access_token := binary(), expires_at := integer()}} +%% | {error, workload_identity_error()}) -> ok), +%% %% %% User interaction %% prompt_otp => fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), %% should_authenticate => fun((Reason :: no_credentials | token_refresh_failed) -> boolean()), @@ -67,6 +81,7 @@ %%
  • Per-repo `auth_key' with optional OAuth exchange (default true for hex.pm)
  • %%
  • Parent repo `auth_key'
  • %%
  • Global OAuth token
  • +%%
  • Workload Identity token (for "hexpm:org" organizations)
  • %% %% %% == OAuth Exchange == @@ -90,9 +105,15 @@ %% == Workload Identity == %% %% Workload Identity, sometimes also known as "Trusted Publishing", lets a -%% supported CI job (currently GitHub Actions) publish without a stored API key. -%% `workload_identity_auth/2' exchanges the job's OIDC token for API auth -%% scoped to one package. See its documentation for details. +%% supported CI job (currently GitHub Actions) publish and fetch without a +%% stored key. `workload_identity_auth/2' exchanges the job's OIDC token for API +%% auth scoped to one package. See its documentation for details. +%% +%% A repository request to a Hex.pm organization ("hexpm:org") that resolves +%% no other credentials exchanges the job's OIDC token for a token scoped to +%% the organization's repository, when the build tool provides the +%% `get_workload_identity_token' and `persist_workload_identity_token' +%% callbacks. A failed exchange isn't repeated in the same run. -module(hex_cli_auth). -export([ @@ -149,6 +170,12 @@ ), clear_oauth_tokens => fun(() -> ok), organization_reauth => fun((Organizations :: [map()]) -> ok), + get_workload_identity_token => fun( + (RepoName :: binary()) -> {ok, oauth_tokens()} | {error, workload_identity_error()} | error + ), + persist_workload_identity_token => fun( + (RepoName :: binary(), {ok, oauth_tokens()} | {error, workload_identity_error()}) -> ok + ), prompt_otp := fun((Message :: binary()) -> {ok, OtpCode :: binary()} | cancelled), should_authenticate := fun((Reason :: auth_prompt_reason()) -> boolean()), get_client_id := fun(() -> binary()) @@ -184,6 +211,7 @@ | {auth_error, device_auth_timeout} | {auth_error, device_auth_denied} | {auth_error, oauth_exchange_failed} + | {auth_error, {workload_identity_failed, workload_identity_error()}} | {auth_error, term()}. -type auth_context() :: #{ @@ -331,13 +359,18 @@ with_repo(BaseConfig, Fun) -> %%
  • `auth_key' from `get_auth_config' when `trusted' is true and `oauth_exchange' is true - exchange for OAuth token
  • %%
  • `auth_key' from `get_auth_config' when `trusted' is true - use directly
  • %%
  • Global OAuth token from `get_oauth_tokens' callback for Hex.pm repositories
  • +%%
  • Workload Identity token for Hex.pm organization repositories, when the +%% build tool provides the Workload Identity callbacks and the CI job can +%% issue OIDC tokens. After a failed exchange the function runs without +%% credentials when `optional' is true, and a 401 or 403 is returned as +%% `{error, {auth_error, {workload_identity_failed, Reason}}}'.
  • %%
  • No auth when `optional' is true (with retry on 401)
  • %%
  • Prompt via `should_authenticate' when `auth_inline' is true
  • %% %% %% A resolved token the server answers with a `token_expired' 401 is renewed at -%% its source (a per-repo token is exchanged again, the global token is -%% refreshed) and the request is run once more. +%% its source (a per-repo or Workload Identity token is exchanged again, the +%% global token is refreshed) and the request is run once more. %% %% The repository name is taken from the config (`repo_name' or `repo_organization'). %% @@ -381,10 +414,24 @@ with_repo(BaseConfig, Fun, Opts) -> {error, {auth_error, Reason}} when Optional =:= true, ?IS_REFRESH_FAILURE(Reason) -> %% Token refresh failed but auth is optional, fall back to no credentials execute_optional_with_retry(repo, BaseConfig, Fun, AuthInline, Opts); + {error, {auth_error, {workload_identity_failed, _Reason}}} = Error when Optional =:= true -> + execute_without_workload_identity(BaseConfig, Fun, Error); {error, _} = Error -> Error end. +%% @private +%% The workload identity was only picked up from the CI job, so a failed +%% exchange leaves the request to run the way it does outside CI: a mirror +%% that authenticates another way still works. A refusal is answered with why +%% the exchange failed. +execute_without_workload_identity(BaseConfig, Fun, Error) -> + case Fun(BaseConfig) of + {ok, {Status, _Headers, _Body}} when Status =:= 401; Status =:= 403 -> Error; + {ok, {Status, _Headers}} when Status =:= 401; Status =:= 403 -> Error; + Other -> Other + end. + %% @private %% Extract repository name from config. -spec repo_name(hex_core:config()) -> binary(). @@ -518,9 +565,11 @@ execute_repo_with_retry(BaseConfig, Fun, RepoKey) -> %% @private renew_repo_auth_and_retry(BaseConfig, Fun, RepoKey, Response) -> - case resolve_repo_auth(BaseConfig, true) of + case resolve_repo_auth(BaseConfig, {rejected, RepoKey}) of {ok, NewRepoKey, _AuthContext} when is_binary(NewRepoKey), NewRepoKey =/= RepoKey -> Fun(BaseConfig#{repo_key => NewRepoKey}); + {error, {auth_error, {workload_identity_failed, _Reason}}} = Error -> + Error; _Other -> Response end. @@ -573,13 +622,20 @@ workload_identity_auth(Config, Scope) -> _NoUsableCredentials -> case hex_oidc:detect_provider() of {ok, Provider} -> - authenticate_workload_identity(Config, Provider, Scope); + case authenticate_workload_identity(Config, Provider, Scope) of + {ok, #{access_token := AccessToken}} -> + {ok, <<"Bearer ", AccessToken/binary>>}; + {error, _Reason} = Error -> + Error + end; none -> none end end. %% @private +-spec authenticate_workload_identity(hex_core:config(), hex_oidc:provider(), binary()) -> + {ok, oauth_tokens()} | {error, workload_identity_error()}. authenticate_workload_identity(Config, Provider, Scope) -> case hex_api_oauth:oidc_audience(Config) of {ok, {200, _Headers, #{<<"audience">> := Audience}}} when is_binary(Audience) -> @@ -596,8 +652,13 @@ authenticate_workload_identity(Config, Provider, Scope) -> %% @private exchange_workload_identity_token(Config, OidcToken, Scope) -> case hex_api_oauth:jwt_bearer_token(Config, OidcToken, Scope) of - {ok, {200, _Headers, #{<<"access_token">> := AccessToken}}} when is_binary(AccessToken) -> - {ok, <<"Bearer ", AccessToken/binary>>}; + {ok, + {200, _Headers, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} when + is_binary(AccessToken), is_integer(ExpiresIn) + -> + {ok, #{ + access_token => AccessToken, expires_at => erlang:system_time(second) + ExpiresIn + }}; Response -> {error, {token_exchange_failed, Response}} end. @@ -674,15 +735,17 @@ resolve_api_auth(_Permission, Config) -> %% 2. trusted + auth_key + oauth_exchange => exchange for OAuth token %% 3. trusted + auth_key => use directly %% 4. trusted Hex.pm or child repository + global OAuth tokens => use those -%% 5. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) +%% 5. trusted Hex.pm child repository + Workload Identity => use or exchange +%% 6. Fallthrough to no_auth (handled by with_repo/3 for optional/auth_inline) -spec resolve_repo_auth(hex_core:config()) -> {ok, binary(), auth_context()} | no_auth | {error, auth_error()}. resolve_repo_auth(Config) -> resolve_repo_auth(Config, false). %% @private -%% Renew says the credential we already have was rejected, so a stored token -%% that has not run out of time is exchanged or refreshed anyway. +%% Renew is `{rejected, RepoKey}' when the server rejected the credential we +%% have, so a stored token that has not run out of time is exchanged or +%% refreshed anyway, and `false' otherwise. resolve_repo_auth(#{repo_key := RepoKey}, _Renew) when is_binary(RepoKey) -> %% repo_key already in config, pass through directly {ok, RepoKey, #{has_refresh_token => false}}; @@ -734,10 +797,16 @@ do_resolve_repo_auth(RepoName, LookupRepo, Config, Renew, Lock) -> do_resolve_repo_auth(RepoName, ParentName, Config, Renew, Lock); _ -> %% 6. trusted Hex.pm or child repository + global OAuth tokens => use those - resolve_global_oauth_for_repo(RepoName, Config, Renew) + case resolve_global_oauth_for_repo(RepoName, Config, Renew) of + no_auth -> + %% 7. trusted Hex.pm child repository + Workload Identity + resolve_workload_identity_for_repo(RepoName, Config, Renew, Lock); + Result -> + Result + end end; _ -> - %% 7. Not trusted, no auth + %% 8. Not trusted, no auth no_auth end. @@ -750,7 +819,7 @@ resolve_global_oauth_for_repo(_RepoName, _Config, _Renew) -> no_auth. resolve_global_oauth_for_repo(Config, Renew) -> - case resolve_oauth_token_with_context(Config, Renew) of + case resolve_oauth_token_with_context(Config, Renew =/= false) of {ok, Token, AuthContext} -> {ok, Token, AuthContext}; {error, no_auth} -> @@ -759,6 +828,70 @@ resolve_global_oauth_for_repo(Config, Renew) -> Error end. +%% @private +%% A Hex.pm organization's repository fetched from a CI job. The build tool +%% keeps the outcome, so a token is reused until it is about to expire, and +%% only an exchange takes the repo lock, like resolve_repo_oauth_token/6. A +%% renewal uses a token another caller exchanged while this one waited for the +%% lock instead of exchanging again. A failure is kept too: each failed +%% exchange counts against the CI job's limit at Hex, and the next one would +%% fail the same way. +resolve_workload_identity_for_repo( + <<"hexpm:", Organization/binary>> = RepoName, Config, Renew, Lock +) -> + case workload_identity_provider(Config) of + {ok, Provider} -> + case call_callback(Config, get_workload_identity_token, [RepoName]) of + {ok, #{access_token := AccessToken, expires_at := ExpiresAt}} -> + BearerToken = <<"Bearer ", AccessToken/binary>>, + case Renew =:= {rejected, BearerToken} orelse is_token_expired(ExpiresAt) of + false -> + {ok, BearerToken, #{has_refresh_token => false}}; + true -> + exchange_workload_identity_for_repo( + RepoName, Organization, Config, Provider, Lock + ) + end; + {error, Reason} -> + {error, {auth_error, {workload_identity_failed, Reason}}}; + error -> + exchange_workload_identity_for_repo( + RepoName, Organization, Config, Provider, Lock + ) + end; + none -> + no_auth + end; +resolve_workload_identity_for_repo(_RepoName, _Config, _Renew, _Lock) -> + no_auth. + +%% @private +%% Without somewhere to keep the token every request would exchange a new OIDC +%% token, so a build tool that provides no Workload Identity callbacks doesn't +%% get Workload Identity for repositories. +workload_identity_provider(#{cli_auth_callbacks := Callbacks}) -> + case + is_map_key(get_workload_identity_token, Callbacks) andalso + is_map_key(persist_workload_identity_token, Callbacks) + of + true -> hex_oidc:detect_provider(); + false -> none + end. + +%% @private +exchange_workload_identity_for_repo(_RepoName, _Organization, _Config, _Provider, unlocked) -> + needs_lock; +exchange_workload_identity_for_repo(RepoName, Organization, Config, Provider, locked) -> + Scope = <<"repository:", Organization/binary>>, + Result = authenticate_workload_identity(exchange_config(Config), Provider, Scope), + ok = call_callback(Config, persist_workload_identity_token, [RepoName, Result]), + case Result of + {ok, #{access_token := AccessToken}} -> + {ok, <<"Bearer ", AccessToken/binary>>, #{has_refresh_token => false}}; + {error, Reason} -> + {error, {auth_error, {workload_identity_failed, Reason}}} + end. + %% @private %% Resolve repo OAuth token: use if valid, re-exchange if expiring or rejected. resolve_repo_oauth_token( @@ -769,7 +902,7 @@ resolve_repo_oauth_token( Renew, Lock ) -> - case {Renew orelse is_token_expired(ExpiresAt), Lock} of + case {Renew =/= false orelse is_token_expired(ExpiresAt), Lock} of {false, _} -> %% Token is still valid, use it BearerToken = <<"Bearer ", AccessToken/binary>>, @@ -786,11 +919,7 @@ resolve_repo_oauth_token( %% Persists the token with the repo name for per-repo token storage. exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) -> ClientId = call_callback(Config, get_client_id, []), - ExchangeConfig = - case maps:get(oauth_exchange_url, Config, undefined) of - undefined -> Config; - OAuthUrl -> Config#{api_url => OAuthUrl} - end, + ExchangeConfig = exchange_config(Config), case hex_api_oauth:client_credentials_token(ExchangeConfig, ClientId, AuthKey, Scope) of {ok, {200, _, #{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}}} -> Tokens = #{ @@ -806,6 +935,14 @@ exchange_for_oauth_token(RepoName, Config, AuthKey, Scope) -> {error, {auth_error, oauth_exchange_failed}} end. +%% @private +%% Repository tokens are exchanged at `oauth_exchange_url' when it is set. +exchange_config(Config) -> + case maps:get(oauth_exchange_url, Config, undefined) of + undefined -> Config; + OAuthUrl -> Config#{api_url => OAuthUrl} + end. + %% @private get_parent_repo_key(Config, RepoName, KeyType) -> case binary:split(RepoName, <<":">>) of diff --git a/test/hex_cli_auth_SUITE.erl b/test/hex_cli_auth_SUITE.erl index b9a3b77..2fa335c 100644 --- a/test/hex_cli_auth_SUITE.erl +++ b/test/hex_cli_auth_SUITE.erl @@ -109,6 +109,14 @@ all() -> workload_identity_auth_no_provider_test, workload_identity_auth_credentials_present_test, workload_identity_auth_audience_failed_test, + + %% resolve_repo_auth tests - Workload Identity + resolve_repo_auth_workload_identity_kept_test, + resolve_repo_auth_workload_identity_kept_failure_test, + resolve_repo_auth_workload_identity_not_kept_test, + resolve_repo_auth_workload_identity_no_provider_test, + resolve_repo_auth_workload_identity_hexpm_test, + resolve_repo_auth_workload_identity_after_oauth_test, {group, oidc_token} ]. @@ -117,7 +125,17 @@ groups() -> {oidc_token, [], [ workload_identity_auth_success_test, workload_identity_auth_token_request_failed_test, - workload_identity_auth_exchange_failed_test + workload_identity_auth_exchange_failed_test, + resolve_repo_auth_workload_identity_test, + resolve_repo_auth_workload_identity_expired_test, + resolve_repo_auth_workload_identity_concurrent_test, + resolve_repo_auth_workload_identity_concurrent_failure_test, + with_repo_workload_identity_failed_test, + with_repo_workload_identity_failed_unauthenticated_test, + with_repo_workload_identity_failed_required_test, + with_repo_token_expired_workload_identity_test, + with_repo_token_expired_workload_identity_renewed_test, + with_repo_token_expired_workload_identity_failed_test ]} ]. @@ -1825,6 +1843,355 @@ workload_identity_auth_exchange_failed_test(_Config) -> ), ok. +%%==================================================================== +%% Test Cases - resolve_repo_auth with Workload Identity +%%==================================================================== + +resolve_repo_auth_workload_identity_test(_Config) -> + %% An organization repository with no other credentials exchanges the CI + %% job's OIDC token for a token scoped to the organization's repository, + %% and hands it to the build tool to keep. + put_github_oidc_env("https://ci.test/token"), + Config = workload_identity_config(self(), error), + Before = erlang:system_time(second), + + ?assertEqual( + {ok, <<"Bearer minted.repository:acme">>, #{has_refresh_token => false}}, + hex_cli_auth:resolve_repo_auth(Config) + ), + + receive + {workload_identity_persisted, <<"hexpm:acme">>, + {ok, #{access_token := AccessToken, expires_at := ExpiresAt}}} -> + ?assertEqual(<<"minted.repository:acme">>, AccessToken), + ?assert(ExpiresAt >= Before + 900) + after 100 -> + error(token_not_persisted) + end, + ok. + +resolve_repo_auth_workload_identity_kept_test(_Config) -> + %% A kept token that is still valid is used without exchanging and without + %% waiting on the repo lock. No fixture answers the CI token URL, so the + %% test crashes if an OIDC token is requested. + put_github_oidc_env("https://ci.test/unreachable"), + Now = erlang:system_time(second), + Kept = {ok, #{access_token => <<"kept_token">>, expires_at => Now + 900}}, + Config = workload_identity_config(self(), Kept), + Holder = hold_locks([{hex_cli_auth, repo, <<"hexpm:acme">>}]), + + ?assertEqual( + {ok, <<"Bearer kept_token">>, #{has_refresh_token => false}}, + resolve_repo_auth_within(Config, 1000) + ), + release_locks(Holder), + ok. + +resolve_repo_auth_workload_identity_kept_failure_test(_Config) -> + %% A failed exchange is kept and returned without exchanging again, since + %% every failed exchange counts against the CI job's limit at Hex. No + %% fixture answers the CI token URL, so the test crashes if an OIDC token is + %% requested. + put_github_oidc_env("https://ci.test/unreachable"), + Config = workload_identity_config(self(), {error, {oidc_token_request_failed, 403}}), + + ?assertEqual( + {error, {auth_error, {workload_identity_failed, {oidc_token_request_failed, 403}}}}, + hex_cli_auth:resolve_repo_auth(Config) + ), + ok. + +resolve_repo_auth_workload_identity_expired_test(_Config) -> + %% A kept token that is about to expire is exchanged again. + put_github_oidc_env("https://ci.test/token"), + Now = erlang:system_time(second), + Kept = {ok, #{access_token => <<"old_token">>, expires_at => Now + 60}}, + Config = workload_identity_config(self(), Kept), + + ?assertEqual( + {ok, <<"Bearer minted.repository:acme">>, #{has_refresh_token => false}}, + hex_cli_auth:resolve_repo_auth(Config) + ), + + receive + {workload_identity_persisted, <<"hexpm:acme">>, + {ok, #{access_token := <<"minted.repository:acme">>}}} -> + ok + after 100 -> + error(token_not_persisted) + end, + ok. + +resolve_repo_auth_workload_identity_concurrent_test(_Config) -> + %% Exchanging holds the repo lock, and the callers that waited for it find + %% the kept token, so concurrent requests to one organization share a + %% single exchange. + put_github_oidc_env("https://ci.test/token"), + Self = self(), + Store = ets:new(workload_identity_tokens, [public]), + Config = workload_identity_store_config(Self, Store), + Holder = hold_locks([{hex_cli_auth, repo, <<"hexpm:acme">>}]), + + [ + spawn_link(fun() -> Self ! {resolved, hex_cli_auth:resolve_repo_auth(Config)} end) + || _ <- lists:seq(1, 3) + ], + + receive + {resolved, Early} -> error({resolved_while_locked, Early}) + after 200 -> + ok + end, + + release_locks(Holder), + [ + receive + {resolved, Result} -> + ?assertEqual( + {ok, <<"Bearer minted.repository:acme">>, #{has_refresh_token => false}}, + Result + ) + after 5000 -> + error(not_resolved_after_release) + end + || _ <- lists:seq(1, 3) + ], + + receive + {workload_identity_persisted, <<"hexpm:acme">>, {ok, _Token}} -> ok + after 0 -> + error(token_not_persisted) + end, + receive + {workload_identity_persisted, _, _} = Again -> error({exchanged_again, Again}) + after 0 -> + ok + end, + ets:delete(Store), + ok. + +resolve_repo_auth_workload_identity_concurrent_failure_test(_Config) -> + %% The callers that waited for the repo lock find the failed exchange kept + %% and return it, so a refusal is exchanged once rather than once per + %% request. + put_github_oidc_env("https://ci.test/token"), + Self = self(), + Store = ets:new(workload_identity_tokens, [public]), + Config = workload_identity_store_config(Self, Store), + Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>}, + Body = #{<<"error">> => <<"access_denied">>, <<"error_description">> => <<"No match">>}, + Refusal = {ok, {403, Headers, term_to_binary(Body)}}, + Holder = hold_locks([{hex_cli_auth, repo, <<"hexpm:acme">>}]), + + [ + spawn_link(fun() -> + self() ! {hex_http_test, jwt_bearer_response, Refusal}, + Self ! {resolved, hex_cli_auth:resolve_repo_auth(Config)} + end) + || _ <- lists:seq(1, 3) + ], + + receive + {resolved, Early} -> error({resolved_while_locked, Early}) + after 200 -> + ok + end, + + release_locks(Holder), + Expected = + {error, + {auth_error, + {workload_identity_failed, {token_exchange_failed, {ok, {403, Headers, Body}}}}}}, + [ + receive + {resolved, Result} -> ?assertEqual(Expected, Result) + after 5000 -> + error(not_resolved_after_release) + end + || _ <- lists:seq(1, 3) + ], + + receive + {workload_identity_persisted, <<"hexpm:acme">>, {error, _Reason}} -> ok + after 0 -> + error(failure_not_persisted) + end, + receive + {workload_identity_persisted, _, _} = Again -> error({exchanged_again, Again}) + after 0 -> + ok + end, + ets:delete(Store), + ok. + +resolve_repo_auth_workload_identity_not_kept_test(_Config) -> + %% A build tool without the Workload Identity callbacks would have every + %% request exchange a new OIDC token, so repositories don't use it. + put_github_oidc_env("https://ci.test/unreachable"), + Config = config_with_callbacks(#{oauth_tokens => error}), + + ?assertEqual( + no_auth, + hex_cli_auth:resolve_repo_auth(Config#{repo_organization => <<"acme">>, trusted => true}) + ), + ok. + +resolve_repo_auth_workload_identity_no_provider_test(_Config) -> + Config = workload_identity_config(self(), error), + ?assertEqual(no_auth, hex_cli_auth:resolve_repo_auth(Config)), + ok. + +resolve_repo_auth_workload_identity_hexpm_test(_Config) -> + %% The public repository needs no credentials, so nothing is exchanged for + %% it. + put_github_oidc_env("https://ci.test/unreachable"), + Config = workload_identity_config(self(), error), + + ?assertEqual( + no_auth, hex_cli_auth:resolve_repo_auth(maps:remove(repo_organization, Config)) + ), + ok. + +resolve_repo_auth_workload_identity_after_oauth_test(_Config) -> + %% A user's own credentials take precedence over the CI job's. + put_github_oidc_env("https://ci.test/unreachable"), + Now = erlang:system_time(second), + Config = workload_identity_config(self(), error), + Callbacks = maps:get(cli_auth_callbacks, Config), + GetOAuthTokens = fun() -> + {ok, #{access_token => <<"global_oauth">>, expires_at => Now + 3600}} + end, + + ?assertEqual( + {ok, <<"Bearer global_oauth">>, #{has_refresh_token => false}}, + hex_cli_auth:resolve_repo_auth( + Config#{cli_auth_callbacks => Callbacks#{get_oauth_tokens => GetOAuthTokens}} + ) + ), + ok. + +with_repo_workload_identity_failed_test(_Config) -> + %% After a refused exchange the request runs without credentials, and the + %% repository refusing it is answered with why the exchange failed. + put_github_oidc_env("https://ci.test/token"), + Config = workload_identity_config(self(), error), + {Headers, Body} = queue_jwt_bearer_refusal(), + + Fun = fun(RequestConfig) -> + ?assertEqual(undefined, maps:get(repo_key, RequestConfig, undefined)), + {ok, {401, #{}, <<"">>}} + end, + + ?assertEqual( + {error, + {auth_error, + {workload_identity_failed, {token_exchange_failed, {ok, {403, Headers, Body}}}}}}, + hex_cli_auth:with_repo(Config, Fun) + ), + + receive + {workload_identity_persisted, <<"hexpm:acme">>, {error, {token_exchange_failed, _}}} -> ok + after 100 -> + error(failure_not_persisted) + end, + ok. + +with_repo_workload_identity_failed_unauthenticated_test(_Config) -> + %% The workload identity was only picked up from the CI job, so a mirror + %% that authenticates another way still answers the request it would have + %% got outside CI. + put_github_oidc_env("https://ci.test/token"), + Config = workload_identity_config(self(), error), + queue_jwt_bearer_refusal(), + + Fun = fun(RequestConfig) -> + ?assertEqual(undefined, maps:get(repo_key, RequestConfig, undefined)), + {ok, {200, #{}, <<"body">>}} + end, + + ?assertEqual({ok, {200, #{}, <<"body">>}}, hex_cli_auth:with_repo(Config, Fun)), + ok. + +with_repo_workload_identity_failed_required_test(_Config) -> + put_github_oidc_env("https://ci.test/token"), + Config = workload_identity_config(self(), error), + {Headers, Body} = queue_jwt_bearer_refusal(), + + ?assertEqual( + {error, + {auth_error, + {workload_identity_failed, {token_exchange_failed, {ok, {403, Headers, Body}}}}}}, + hex_cli_auth:with_repo( + Config, fun(_RequestConfig) -> error(request_made) end, [{optional, false}] + ) + ), + ok. + +with_repo_token_expired_workload_identity_test(_Config) -> + %% A kept token the repository answers token_expired for is exchanged again + %% even though its expiry has not passed. + put_github_oidc_env("https://ci.test/token"), + Now = erlang:system_time(second), + Kept = {ok, #{access_token => <<"stale_token">>, expires_at => Now + 900}}, + Config = workload_identity_config(self(), Kept), + + Fun = fun(Cfg) -> + case maps:get(repo_key, Cfg) of + <<"Bearer stale_token">> -> token_expired_response(); + RepoKey -> RepoKey + end + end, + + ?assertEqual(<<"Bearer minted.repository:acme">>, hex_cli_auth:with_repo(Config, Fun)), + ok. + +with_repo_token_expired_workload_identity_renewed_test(_Config) -> + %% A token another request already renewed is used instead of exchanging + %% again. No fixture answers the CI token URL, so the test crashes if an + %% OIDC token is requested. + put_github_oidc_env("https://ci.test/unreachable"), + Now = erlang:system_time(second), + Reads = counters:new(1, []), + Config = workload_identity_config(self(), fun() -> + counters:add(Reads, 1, 1), + case counters:get(Reads, 1) of + 1 -> {ok, #{access_token => <<"stale_token">>, expires_at => Now + 900}}; + _ -> {ok, #{access_token => <<"renewed_token">>, expires_at => Now + 900}} + end + end), + + Fun = fun(Cfg) -> + case maps:get(repo_key, Cfg) of + <<"Bearer stale_token">> -> token_expired_response(); + RepoKey -> RepoKey + end + end, + + ?assertEqual(<<"Bearer renewed_token">>, hex_cli_auth:with_repo(Config, Fun)), + ok. + +with_repo_token_expired_workload_identity_failed_test(_Config) -> + %% The request needed the token it was renewing, so a refused renewal is + %% returned instead of the 401 that asked for it. + put_github_oidc_env("https://ci.test/token"), + Now = erlang:system_time(second), + Kept = {ok, #{access_token => <<"stale_token">>, expires_at => Now + 900}}, + Config = workload_identity_config(self(), Kept), + {Headers, Body} = queue_jwt_bearer_refusal(), + + Fun = fun(Cfg) -> + <<"Bearer stale_token">> = maps:get(repo_key, Cfg), + token_expired_response() + end, + + ?assertEqual( + {error, + {auth_error, + {workload_identity_failed, {token_exchange_failed, {ok, {403, Headers, Body}}}}}}, + hex_cli_auth:with_repo(Config, Fun) + ), + ok. + %%==================================================================== %% Helper Functions %%==================================================================== @@ -2029,6 +2396,55 @@ put_github_oidc_env(Url) -> os:putenv("ACTIONS_ID_TOKEN_REQUEST_URL", Url), os:putenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "request_token"). +%% @private +%% The acme organization's repository with no configured credentials, where +%% the build tool keeps Workload Identity outcomes, holds Kept (or what the +%% Kept fun returns on each read), and reports what it is asked to keep to Pid. +workload_identity_config(Pid, Kept) -> + Read = + case is_function(Kept, 0) of + true -> Kept; + false -> fun() -> Kept end + end, + Config = config_with_callbacks(#{ + oauth_tokens => error, + get_workload_identity_token => fun(<<"hexpm:acme">>) -> Read() end, + persist_workload_identity_token => fun(RepoName, Result) -> + Pid ! {workload_identity_persisted, RepoName, Result}, + ok + end + }), + Config#{repo_organization => <<"acme">>, trusted => true}. + +%% @private +%% Like workload_identity_config/2, but the outcomes are kept in Store, so +%% concurrent callers see what the others kept. +workload_identity_store_config(Pid, Store) -> + Config = config_with_callbacks(#{ + oauth_tokens => error, + get_workload_identity_token => fun(RepoName) -> + case ets:lookup(Store, RepoName) of + [{RepoName, Result}] -> Result; + [] -> error + end + end, + persist_workload_identity_token => fun(RepoName, Result) -> + ets:insert(Store, {RepoName, Result}), + Pid ! {workload_identity_persisted, RepoName, Result}, + ok + end + }), + Config#{repo_organization => <<"acme">>, trusted => true}. + +%% @private +%% Plants a refusal for the next jwt-bearer token exchange and returns the +%% headers and decoded body it is answered with. +queue_jwt_bearer_refusal() -> + Headers = #{<<"content-type">> => <<"application/vnd.hex+erlang; charset=utf-8">>}, + Body = #{<<"error">> => <<"access_denied">>, <<"error_description">> => <<"No match">>}, + self() ! {hex_http_test, jwt_bearer_response, {ok, {403, Headers, term_to_binary(Body)}}}, + {Headers, Body}. + %% @private %% Plants the next OIDC audience response the test HTTP adapter will hand back. queue_oidc_audience_response(Payload) -> @@ -2120,7 +2536,7 @@ make_callbacks(Opts) -> DefaultGetOAuthTokens = fun() -> maps:get(oauth_tokens, Opts, error) end, GetOAuthTokensFn = maps:get(get_oauth_tokens, Opts, DefaultGetOAuthTokens), - #{ + Callbacks = #{ get_auth_config => fun(RepoName) -> maps:get(RepoName, AuthConfig, undefined) end, get_oauth_tokens => GetOAuthTokensFn, persist_oauth_tokens => PersistFn, @@ -2129,4 +2545,8 @@ make_callbacks(Opts) -> prompt_otp => PromptOtp, should_authenticate => ShouldAuthenticate, get_client_id => fun() -> <<"test_client">> end - }. + }, + WorkloadIdentityCallbacks = maps:with( + [get_workload_identity_token, persist_workload_identity_token], Opts + ), + maps:merge(Callbacks, WorkloadIdentityCallbacks). diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 96ad4ca..b8b8d27 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -426,8 +426,9 @@ fixture(post, <>, _, {_, Body}) -> {hex_http_test, jwt_bearer_response, Response} -> Response after 0 -> + % Named after the scope, so a test can tell what was asked for #{<<"scope">> := Scope} = DecodedBody, - AccessToken = base64:encode(crypto:strong_rand_bytes(32)), + AccessToken = <<"minted.", Scope/binary>>, Payload = #{ <<"access_token">> => AccessToken, <<"token_type">> => <<"bearer">>,