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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# CHANGELOG

## Unreleased

* 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`.
* When the server answers the `repo_key` from the `get_auth_config` callback with an `invalid_token` 401, `hex_cli_auth:with_repo/2,3` skips that `repo_key` and runs the request again with the parent repository's credentials, the global OAuth token or Workload Identity, instead of returning the 401. The fallback credentials are resolved the way a first request resolves them, so a stored token that hasn't expired is used without a refresh.

## v0.20.1 (2026-10-14)

* Fix `hex_tarball:unpack/2,3` and `hex_tarball:unpack_docs/2,3` looping forever when unpacking to disk a tarball with a GNU sparse member that ends in a hole, and failing on a sparse hole that crosses a 64 KiB boundary in the file, by updating the vendored `erl_tar` to OTP 29.1.2 ([GHSA-phm5-m6cj-5382](https://github.com/erlang/otp/security/advisories/GHSA-phm5-m6cj-5382), CVE-2026-96596).
Expand Down
6 changes: 3 additions & 3 deletions src/hex_api.erl
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,10 @@ to_list(Str) -> unicode:characters_to_list(Str).
%% TODO: not needed after exdoc is fixed
%% @private
detect_otp_error({ok, {401, Headers, Body}}) ->
case maps:get(<<"www-authenticate">>, Headers, nil) of
<<"Bearer realm=\"hex\", error=\"totp_required\"", _/binary>> ->
case hex_http:bearer_error(Headers) of
<<"totp_required">> ->
{error, otp_required};
<<"Bearer realm=\"hex\", error=\"invalid_totp\"", _/binary>> ->
<<"invalid_totp">> ->
{error, invalid_totp};
_ ->
{ok, {401, Headers, Body}}
Expand Down
21 changes: 15 additions & 6 deletions src/hex_api_oauth.erl
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ device_authorization(Config, ClientId, Scope, Opts) ->
<<"scope">> => Scope,
<<"name">> => Name
},
hex_api:post(Config, Path, Params).
hex_api:post(grant_config(Config), Path, Params).

%% @doc
%% Runs the complete OAuth device authorization flow.
Expand Down Expand Up @@ -266,7 +266,7 @@ poll_device_token(Config, ClientId, DeviceCode) ->
<<"device_code">> => DeviceCode,
<<"client_id">> => ClientId
},
hex_api:post(Config, Path, Params).
hex_api:post(grant_config(Config), Path, Params).

%% @doc
%% Refreshes an access token using a refresh token.
Expand All @@ -292,7 +292,7 @@ refresh_token(Config, ClientId, RefreshToken) ->
<<"refresh_token">> => RefreshToken,
<<"client_id">> => ClientId
},
hex_api:post(Config, Path, Params).
hex_api:post(grant_config(Config), Path, Params).

%% @doc
%% Requests a browser URL to verify the organizations' SSO and 2FA requirements
Expand Down Expand Up @@ -373,7 +373,7 @@ client_credentials_token(Config, ClientId, ApiKey, Scope, Opts) ->
undefined -> Params0;
Name -> Params0#{<<"name">> => Name}
end,
hex_api:post(Config, Path, Params).
hex_api:post(grant_config(Config), Path, Params).

%% @doc
%% Exchanges an OIDC token from a CI provider for an access token scoped to
Expand All @@ -397,7 +397,7 @@ jwt_bearer_token(Config, Assertion, Scope) ->
<<"assertion">> => Assertion,
<<"scope">> => Scope
},
hex_api:post(Config, Path, Params).
hex_api:post(grant_config(Config), Path, Params).

%% @doc
%% Fetches the audience Workload Identity OIDC tokens must be issued for.
Expand All @@ -412,7 +412,7 @@ jwt_bearer_token(Config, Assertion, Scope) ->
%% @end
-spec oidc_audience(hex_core:config()) -> hex_api:response().
oidc_audience(Config) ->
hex_api:get(Config, <<"oidc/audience">>).
hex_api:get(grant_config(Config), <<"oidc/audience">>).

%% @doc
%% Revokes an OAuth token (RFC 7009).
Expand Down Expand Up @@ -488,6 +488,15 @@ valid_requirements(_) ->
%% Internal functions
%%====================================================================

%% @private
%% The device authorization and token endpoints and the OIDC audience take no
%% credential: a grant is authenticated by what's in the request body. hexpm
%% refuses a request whose credential it refuses before it looks at the
%% grant, and the `api_key' in the config is often the token being renewed,
%% so it isn't sent.
grant_config(Config) ->
maps:remove(api_key, Config).

%% @private
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
Expand Down
Loading
Loading