From 700215e3b7131ecf7e3e2dbd2bd7c92b481676da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Tue, 20 Oct 2026 01:55:13 +0200 Subject: [PATCH] Only request the CI token for the configured OIDC audience Workload Identity asked the CI provider for a token with whatever audience GET /oidc/audience returned. The provider issues a token for any audience, so a configured API that isn't Hex.pm, or anyone in the middle of a plain HTTP one, could choose the audience of another service that trusts the job, such as sts.amazonaws.com, and receive a token that service accepts. The new workload_identity_audience config option, hexpm by default, is the only audience requested. An API advertising another one gets {error, {oidc_audience_not_allowed, Audience}}. --- CHANGELOG.md | 1 + src/hex_cli_auth.erl | 13 +++++++++++-- src/hex_core.erl | 11 +++++++++-- test/hex_cli_auth_SUITE.erl | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3316c5a..6029616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/src/hex_cli_auth.erl b/src/hex_cli_auth.erl index 9a5f04b..d95fc02 100644 --- a/src/hex_cli_auth.erl +++ b/src/hex_cli_auth.erl @@ -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. @@ -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. diff --git a/src/hex_core.erl b/src/hex_core.erl index 0ca8bc4..3a970c9 100644 --- a/src/hex_core.erl +++ b/src/hex_core.erl @@ -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]). @@ -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(). @@ -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">> }. diff --git a/test/hex_cli_auth_SUITE.erl b/test/hex_cli_auth_SUITE.erl index fcfcff8..8d5932c 100644 --- a/test/hex_cli_auth_SUITE.erl +++ b/test/hex_cli_auth_SUITE.erl @@ -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, @@ -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, @@ -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(#{}),