Skip to content

Carry the organizations a session must re-authenticate for - #213

Open
ericmj wants to merge 6 commits into
mainfrom
organization-sso-reauth
Open

Carry the organizations a session must re-authenticate for#213
ericmj wants to merge 6 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

@ericmj ericmj commented Aug 4, 2026

Copy link
Copy Markdown
Member

Carries the organizations a session must re-authenticate for from the token response through to the CLI.

hex_api_oauth parses sso_reauth_required out of a token response and threads it through refresh_token/3 and the device-auth result. hex_cli_auth reports it through a new optional sso_reauth callback, invoked after every token grant with the organizations the server says this session has to authenticate through their identity provider for. Clients that do not register the callback are unaffected.

Needed by hexpm/hex#organization-sso-reauth, which registers the callback and prompts.

An organization can require its members to authenticate through its
identity provider, and that authentication expires on a clock the
organization sets. When it lapses, the token grant drops the organization's
scopes and names them in sso_reauth_required rather than leaving the client
to guess why a fetch started 403ing.

The name is the whole point: a scope dropped because the member was removed
is not named, because authenticating again would not give it back. Only the
ones a browser visit would fix are.

sso_authorization/2 asks for the URL that does the fixing. It is bound to
the session asking, so opening it renews that session rather than starting
a new one, and refresh_tokens/1 is how a build tool picks up the scopes
afterwards without waiting out an access token that has not expired.

The sso_reauth callback is optional and reports the flagged set after every
grant, including as an empty list. Which of them the running command
actually needs is the build tool's question, not this module's.
ericmj added 5 commits August 21, 2026 22:04
report_sso_reauth/2 is one clause taking the list; the refresh path
normalizes the raw token response with hex_api_oauth:sso_reauth_required/1
(now an exported private helper) and the device path destructures the map
device_auth_flow already normalized.

with_token_refresh_lock/2 carries the global lock and stored-token fetch
for both resolution and forced refresh; a failed forced refresh still
leaves the stored token in place for the caller to handle.

has_refresh_token is derived from the device-auth result instead of
hardcoded, the unreachable env source leaves the auth_context type,
open_browser/1 is private again, and is_token_expired/1 is exported so
build tools stop reimplementing the expiry buffer.
A repository request that triggered inline device auth retried with the
API key set, and repository requests read repo_key, so the retry went
out unauthenticated and 401ed again. It re-resolves repository auth
after authenticating. with_repo also executed once and never acted on a
401 from a token it had already resolved, so a token the server rejects
as expired was never refreshed or re-exchanged; it retries once with
renewed credentials.

A transport failure during a refresh was reported as an expired session
and dropped the stored token, so a DNS blip printed that the session had
expired and downgraded the rest of the run to anonymous. Only a refusal
from the server clears the token now.

with_repo documented auth_inline as defaulting to false and then took
true on the 401 path, so a private package could open an interactive
device flow against the caller's stated default.

One transport error while polling ended a device authorization the user
was minutes into. Polling continues until the device code expires.

A malformed verification URI crashed the CLI, because the URL check
threw where its caller expected a return value.

The refresh token sentinel had two owners: an absent one was undefined
in one place and absent in another, and undefined reached persistence,
where Elixir stored it as a refresh token. The grant is normalized once.

has_refresh_token was derived three ways in one module, and the source
member of the auth context was written on eight paths and read nowhere.
Escape cmd.exe metacharacters before handing a server-supplied URL to the
Windows browser opener. erts quotes an argument only when it contains a
space or a quote, so a verification URI carrying a bare & reached cmd as a
command separator.

Classify a refresh response by what it says. Only 400 and 401 mean the
refresh token is dead and clear the stored credentials; every other status,
a transport failure, and a 200 whose body does not carry a usable
access_token and expires_in leave the token in place.

Renew on a token_expired challenge instead of resolving the same bearer
again, and bound the renewals. The api path resolved with renewal disabled,
so a token outside its expiry window came back unchanged and the request
retried forever. The retry budget lives with the other retry state so it
survives a reauthentication rather than resetting.

Validate the device authorization, poll and refresh responses before
destructuring them, so a malformed 200 returns an error the callers already
handle instead of a badmatch, a badarith, or a badarg out of timer:sleep.

Distinguish a missing sso_reauth_required from a malformed one. Missing
still means nothing lapsed, for servers that do not send it; a value that
is not a list of binaries no longer reads as an authoritative empty set
that clears the stored organizations.

Hold the device authentication lock over credential acquisition only.
Taking a lock the same process already holds adds no reference, and
releasing the inner one deleted the entry, so the outer body ran unlocked
and a second process could start a concurrent device authentication.
`with_api/4` consulted `should_authenticate` only for `{error, no_auth}`. A
refused refresh resolves to `{error, {auth_error, token_refresh_failed}}`,
whose only clause required `optional`, which `with_session_api` does not pass,
so a caller that asked to be prompted up front got three "run mix hex.user
auth" messages in one `mix deps.get` and no prompt.

Only for a refused refresh, not `token_refresh_unavailable`: there the refresh
got no answer at all, so the token may still be good and the network is what is
wrong, and a device flow needing the same network is no help.
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.

2 participants