Repository navigation
Renew on invalid_token and fall back from a rejected repo_key - #244
Merged
Merged
Conversation
with_repo/3 answers a token_expired 401 by resolving repository auth
again with Renew = {rejected, RepoKey}. A repo_key from get_auth_config
passed through as step 1 both times, so renew_repo_auth_and_retry/4 got
the same credential back and returned the 401, even when a global OAuth
token or Workload Identity could authenticate the request.
The resolution now drops a repo_key equal to the rejected one and
continues as if get_auth_config hadn't returned it: other credentials in
the same auth config, the parent repository, the global OAuth token,
then Workload Identity. Renew becomes {skipped, RepoKey} for the rest of
the resolution. That skips the same repo_key when the build tool returns
it for the parent repository as well, and keeps the credentials after it
from being renewed. They weren't rejected, and forcing a refresh of a
session token that has no refresh token clears it. The skip runs in both
the unlocked and the locked pass, so only exchanges take the repo lock.
A repo_key in the config is still passed through. The caller chose it
for that request, so its 401 is returned to the caller.
hex_cli_auth renewed a credential on a 401 carrying error="token_expired", which no Hex.pm service sends. It now renews on the RFC 6750 challenge for an access token that's expired, revoked or doesn't verify, `Bearer realm="hex", error="invalid_token"`, which hexpm sends for API requests and repo.hex.pm for organization repositories. This covers API and repository requests, including the fallback from a rejected repo_key.
hex_api sends the config's api_key as the authorization header, and hex_cli_auth renews a refused API token with the request's config, which still holds the refused token. hexpm authenticates an API request before routing it, so the refresh, or the device authorization that follows a failed one, was answered with the same 401, and refresh_or_clear/2 cleared the stored session token. Nothing reached this until a server sent the challenge hex_cli_auth renews on. The device authorization, token endpoint and OIDC audience requests in hex_api_oauth now drop api_key from the config; a grant is authenticated by the request body. organization_authorization/2 still sends it, since it's authorized by the session's access token. The test HTTP adapter refuses those three endpoints when a request carries an authorization header, as hexpm does for the credentials the tests use.
hex_api and hex_cli_auth matched the header against the exact prefix `Bearer realm="hex", error="..."`, so a challenge with its parameters in another order or case, a token value, no realm, or another challenge in front of it wasn't recognized. hex_http:bearer_error/1 parses the header per RFC 9110 sections 11.2 and 11.6.1 (several challenges, a token68 or parameters after the scheme, case-insensitive scheme and parameter names, token or quoted-string values) and returns the error of the Bearer challenge. Both now use it to find invalid_token, totp_required and invalid_totp. A 403 with insufficient_scope isn't renewed: the token is valid, it doesn't grant the request.
A renewal refreshed or exchanged whatever stored token it found, and after
a skipped repo_key it renewed nothing, so a fallback token identical to
the rejected repo_key was sent again, and requests rejected together each
refreshed the session. A stored token is now renewed when it's the one
the server rejected, or has expired, and used as it is otherwise, so
callers that waited for the lock use the token another caller refreshed.
Renew is `false` or `{rejected, Credential}` again.
with_api's retries carried the rejected api_key inside the config, so
re-authentication resolved straight back to it and prompted even when
another request had stored a new session. The caller's config and the
credential are now kept apart, and re-authentication resolves from the
caller's config and compares with the rejected credential. A refresh that
gets no usable answer is returned as token_refresh_unavailable instead of
offering to authenticate again.
hex_http:bearer_error/1 skips empty elements at the start of a
challenge's parameter list and returns the error of the first Bearer
challenge that has one.
ericmj
force-pushed
the
rejected-repo-key-fallback
branch
from
October 19, 2026 10:15
19c2803 to
838b5c3
Compare
ericmj
marked this pull request as ready for review
October 19, 2026 16:32
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
hex_cli_authrenews a credential and runs the request again when the server answers with a 401 whose Bearer challenge haserror="invalid_token"(RFC 6750 section 3.1), which hexpm starts sending in hexpm/hexpm#2061. It only recognizederror="token_expired"before, which no Hex.pm service sends, so none of the renewals ran.repo_keyfrom theget_auth_configcallback can't be renewed, so when the server rejects itwith_repo/2,3skips it and runs the request again with the parent repository's credentials, the global OAuth token or Workload Identity. This is for Hex returning an organization's short-lived token asrepo_keyforhexpm:ORG. Arepo_keyin the config is still passed through, since the caller chose it for that request.repo_keyis renewed rather than sent again.with_apikeeps the caller's config apart from the credential on retries, so re-authentication resolves from the caller's config and uses a session another request stored instead of prompting. A refresh that gets no usable answer returnstoken_refresh_unavailableinstead of offering to authenticate again.hex_api_oauthno longer sends the config'sapi_keywith the device authorization, token and OIDC audience requests. Renewing a refused API token sent the refused token along, the server refused the refresh, andhex_cli_authcleared the stored token.WWW-Authenticateis read the way RFC 9110 section 11.6.1 defines it, through the newhex_http:bearer_error/1: several challenges, parameters in any order and case, empty list elements, token or quoted-string values and an optionalrealm. It's used forinvalid_tokenand the two-factortotp_requiredandinvalid_totperrors. A 403 withinsufficient_scopeisn't renewed.