Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

* Request the CI job's OIDC token only for the audience in the new `workload_identity_audience` config option, `hexpm` by default, and return `{error, {oidc_audience_not_allowed, Audience}}` when the API advertises a different one, so a configured API can't get a token that another service accepts.
* Don't send the config's `api_key` with the device authorization, token and OIDC audience requests in `hex_api_oauth`. The server refuses a request carrying a credential it refuses, so renewing a refused API token had its refresh or device authorization refused too, and the refused refresh cleared the stored token.
* Renew a credential and run the request again when the server answers with a 401 whose Bearer challenge has `error="invalid_token"`, the RFC 6750 error for an access token that has expired, been revoked or doesn't verify. A stored token is only refreshed or exchanged when it's the one the server rejected, so requests rejected together share one renewal. A refresh that gets no usable answer returns `{error, {auth_error, token_refresh_unavailable}}`, and when the token can't be renewed, a session another request stored in the meantime is used before asking to authenticate again. The `error="token_expired"` challenge is no longer recognized.
* Read the `WWW-Authenticate` header the way RFC 9110 defines it when looking for `invalid_token` and the two-factor `totp_required` and `invalid_totp` errors: the header can hold several challenges, the Bearer challenge's parameters can come in any order with any case, values can be tokens or quoted strings, and `realm` is optional. Only the exact `Bearer realm="hex", error="..."` prefix was recognized before. Add `hex_http:bearer_error/1`.
Expand Down
13 changes: 11 additions & 2 deletions src/hex_cli_auth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@
-type workload_identity_error() ::
hex_oidc:fetch_error()
| {oidc_audience_failed, hex_api:response()}
%% The API advertised an audience other than the configured
%% workload_identity_audience
| {oidc_audience_not_allowed, Audience :: binary()}
| {token_exchange_failed, hex_api:response()}.

%% How much of each retry budget a request has already spent.
Expand Down Expand Up @@ -640,14 +643,20 @@ workload_identity_auth(Config, Scope) ->
-spec authenticate_workload_identity(hex_core:config(), hex_oidc:provider(), binary()) ->
{ok, oauth_tokens()} | {error, workload_identity_error()}.
authenticate_workload_identity(Config, Provider, Scope) ->
Expected = maps:get(workload_identity_audience, Config, <<"hexpm">>),
case hex_api_oauth:oidc_audience(Config) of
{ok, {200, _Headers, #{<<"audience">> := Audience}}} when is_binary(Audience) ->
case hex_oidc:fetch_token(Config, Provider, Audience) of
{ok, {200, _Headers, #{<<"audience">> := Expected}}} ->
case hex_oidc:fetch_token(Config, Provider, Expected) of
{ok, OidcToken} ->
exchange_workload_identity_token(Config, OidcToken, Scope);
{error, _Reason} = Error ->
Error
end;
%% The CI provider issues the token for whatever audience is asked for,
%% so taking the API's word for it would let any configured API get a
%% token that another service trusting the job accepts.
{ok, {200, _Headers, #{<<"audience">> := Audience}}} when is_binary(Audience) ->
{error, {oidc_audience_not_allowed, Audience}};
Response ->
{error, {oidc_audience_failed, Response}}
end.
Expand Down
11 changes: 9 additions & 2 deletions src/hex_core.erl
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@
%% When set to a list, the metadata decoder streams past unrequested fields without
%% buffering their tokens, which keeps peak memory bounded for packages with very
%% large fields like `<<"files">>'. Defaults to `all'.
%%
%% * `workload_identity_audience' - The OIDC audience Workload Identity requests
%% the CI job's token for. The audience the API advertises must be this one, so
%% the configured API can't get a token for another service that accepts the
%% job's tokens (default: `hexpm').

-module(hex_core).
-export([default_config/0]).
Expand Down Expand Up @@ -132,7 +137,8 @@
trusted => boolean(),
oauth_exchange => boolean(),
oauth_exchange_url => binary() | undefined,
cli_auth_callbacks => hex_cli_auth:callbacks() | undefined
cli_auth_callbacks => hex_cli_auth:callbacks() | undefined,
workload_identity_audience => binary()
}.

-spec default_config() -> config().
Expand Down Expand Up @@ -165,5 +171,6 @@ default_config() ->
trusted => true,
oauth_exchange => true,
oauth_exchange_url => undefined,
cli_auth_callbacks => undefined
cli_auth_callbacks => undefined,
workload_identity_audience => <<"hexpm">>
}.
36 changes: 36 additions & 0 deletions test/hex_cli_auth_SUITE.erl
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ all() ->
workload_identity_auth_no_provider_test,
workload_identity_auth_credentials_present_test,
workload_identity_auth_audience_failed_test,
workload_identity_auth_audience_not_allowed_test,

%% resolve_repo_auth tests - Workload Identity
resolve_repo_auth_workload_identity_kept_test,
Expand All @@ -134,6 +135,7 @@ groups() ->
[
{oidc_token, [], [
workload_identity_auth_success_test,
workload_identity_auth_configured_audience_test,
workload_identity_auth_token_request_failed_test,
workload_identity_auth_exchange_failed_test,
resolve_repo_auth_workload_identity_test,
Expand Down Expand Up @@ -2112,6 +2114,40 @@ workload_identity_auth_audience_failed_test(_Config) ->
),
ok.

workload_identity_auth_audience_not_allowed_test(_Config) ->
%% No fixture answers the CI token URL, so the test crashes if an OIDC
%% token is requested for the advertised audience.
put_github_oidc_env("https://ci.test/unreachable"),
Config = config_with_callbacks(#{}),

queue_oidc_audience_response(#{<<"audience">> => <<"sts.amazonaws.com">>}),

?assertEqual(
{error, {oidc_audience_not_allowed, <<"sts.amazonaws.com">>}},
hex_cli_auth:workload_identity_auth(Config, <<"package:hexpm/foo">>)
),
ok.

workload_identity_auth_configured_audience_test(_Config) ->
put_github_oidc_env("https://ci.test/token"),
Config = (config_with_callbacks(#{}))#{workload_identity_audience => <<"internal">>},

queue_oidc_audience_response(#{<<"audience">> => <<"internal">>}),
queue_jwt_bearer_response(#{<<"access_token">> => <<"minted_token">>}),

?assertEqual(
{ok, <<"Bearer minted_token">>},
hex_cli_auth:workload_identity_auth(Config, <<"package:hexpm/foo">>)
),
?assertEqual(
{error, {oidc_audience_not_allowed, <<"hexpm">>}},
hex_cli_auth:workload_identity_auth(
(config_with_callbacks(#{}))#{workload_identity_audience => <<"internal">>},
<<"package:hexpm/foo">>
)
),
ok.

workload_identity_auth_token_request_failed_test(_Config) ->
put_github_oidc_env("https://ci.test/token"),
Config = config_with_callbacks(#{}),
Expand Down
Loading