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">>,