Skip to content

Renew on invalid_token and fall back from a rejected repo_key - #244

Merged
ericmj merged 5 commits into
mainfrom
rejected-repo-key-fallback
Oct 19, 2026
Merged

ericmj merged 5 commits into
mainfrom
rejected-repo-key-fallback

Conversation

@ericmj

@ericmj ericmj commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

hex_cli_auth renews a credential and runs the request again when the server answers with a 401 whose Bearer challenge has error="invalid_token" (RFC 6750 section 3.1), which hexpm starts sending in hexpm/hexpm#2061. It only recognized error="token_expired" before, which no Hex.pm service sends, so none of the renewals ran.

  • A repo_key from the get_auth_config callback can't be renewed, so when the server rejects it with_repo/2,3 skips 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 as repo_key for hexpm:ORG. A repo_key in the config is still passed through, since the caller chose it for that request.
  • A stored token is refreshed or exchanged only when it's the one the server rejected, or has expired. Requests rejected together share one renewal, and a fallback token identical to the rejected repo_key is renewed rather than sent again.
  • with_api keeps 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 returns token_refresh_unavailable instead of offering to authenticate again.
  • hex_api_oauth no longer sends the config's api_key with the device authorization, token and OIDC audience requests. Renewing a refused API token sent the refused token along, the server refused the refresh, and hex_cli_auth cleared the stored token.
  • WWW-Authenticate is read the way RFC 9110 section 11.6.1 defines it, through the new hex_http:bearer_error/1: several challenges, parameters in any order and case, empty list elements, token or quoted-string values and an optional realm. It's used for invalid_token and the two-factor totp_required and invalid_totp errors. A 403 with insufficient_scope isn't renewed.

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
ericmj force-pushed the rejected-repo-key-fallback branch from 19c2803 to 838b5c3 Compare October 19, 2026 10:15
@ericmj
ericmj marked this pull request as ready for review October 19, 2026 16:32
@ericmj
ericmj merged commit cefae8f into main Oct 19, 2026
10 checks passed
@ericmj
ericmj deleted the rejected-repo-key-fallback branch October 19, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant