Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Ask about SSO only where the answer would change anything - #1217

Merged
ericmj merged 12 commits into
mainfrom
organization-sso-reauth
Sep 1, 2026
Merged

Ask about SSO only where the answer would change anything#1217
ericmj merged 12 commits into
mainfrom
organization-sso-reauth

Conversation

@ericmj

Copy link
Copy Markdown
Member

Prompts once, at the terminal, when an organization the project depends on needs its SSO session renewed.

mix deps.get already knows every repository it needs before it fetches anything, because a published package's dependencies can only come from the public repository or the package's own organization. That set is intersected with the organizations the last token refresh flagged, so a member of ten SSO organizations who depends on two is asked about two:

acme requires SSO authentication. Authenticate now? [Yn]

On yes it requests a re-authorization URI, prints it, and waits. Completing it in a browser renews the existing session rather than replacing it, so the refresh token survives and nobody re-runs device auth. On no it warns and carries on without those packages.

It asks only where the answer would change something. A repository authenticated with an organization key never touches the stored token, so nothing about it is worth asking. Offline says so instead of prompting, and HEX_API_KEY says it authenticates as itself.

Vendors hexpm/hex_core#213, which has to merge first.

An organization that requires SSO expires its members' access on a clock it
sets, and when that lapses the token grant stops carrying the
organization's scope and says which ones it dropped for that reason. The
first thing the client can do with that is ask.
Asking once for the batch is the part that takes work. Every private
organization a resolution can need is named in the project's own
dependencies, since a published package's dependencies come from the public
repository or from its own organization, so the needed set is known before
any fetch. Intersecting it with what the server flagged is what makes the
question "acme requires SSO authentication. Authenticate now?" instead of a
403 halfway through, and it is why a member of ten SSO organizations who
depends on two is asked about two.
Saying yes gets a URL bound to this session, so the session and its refresh
token survive; the refresh afterwards is what picks up the scopes, since
the access token that lacks them has not expired and would otherwise be
used for another half hour. Saying no says what it costs and continues.
The flag is stored with the token rather than kept for the run. It is
learned at refresh, and a later run inside the access token's 30 minutes
does not refresh, so keeping it in memory would mean the second mix
deps.get 403s with nothing to explain it.
CI is untouched: it authenticates as the organization, which is never
governed and never flagged.
Adversarial review of the prompt found four ways it fires or fails badly.
It was not gated on whether the resolution uses the stored token at all. An
organization authenticated with its own key, or a build running with
HEX_REPOS_KEY, fetches without ever touching it, so the prompt asked about
an organization that was already working. check_and_refresh_auth one line up
had that gate; this now uses the same one.
Offline and HEX_API_KEY both say so instead of asking. Offline, the flow
would go to the network for a URL it cannot use. With HEX_API_KEY set, the
request authenticates as the key rather than as the session that needs
authorizing, so the URL would come back bound to the wrong thing and the
flag would never clear.
A verification_uri that is not an http URL threw out of open_browser and
took the resolution with it, after the user had already answered yes, and a
"message" that was an object rather than a string raised on interpolation.
Opening a browser is a convenience on top of a printed URL, so it no longer
ends anything.
The URL is in the prompt rather than beside it. Mix.Shell.Quiet drops info
output and keeps prompts, so under --quiet the flow asked people to finish
in a browser without telling them where.
@ericmj
ericmj marked this pull request as ready for review August 5, 2026 20:06
@ericmj
ericmj requested a review from maennchenAugust 5, 2026 20:06
All 30 conflicts were in the vendored hex_core files: both sides had
revendored from different hex_core commits (766ae61 on this branch,
cf6a12c on main). Resolved by merging hex_core main into hex_core's
organization-sso-reauth branch (a6e8a52) and re-running
scripts/vendor_hex_core.sh against it, no manual edits to vendored
files. auth.ex and remote_converger.ex were touched by both sides and
auto-merged cleanly.
A stale release_docs.sh artifact swept into d111676 by a careless add.
Nothing references it.
The prefetch walk that decides whether authentication is worth
refreshing and the one that decides which organizations to ask about
now share one pass that dedupes repositories before the config lookup,
so a project with two hundred packages from one organization does one
lookup rather than two hundred.
Persisting a refreshed token carries the flagged organizations over
instead of dropping and rewriting them, which removes the second config
write on every steady-state refresh; one token_map/4 builds the stored
shape everywhere, and the raw device-flow map is normalized before
storage so an empty flag list is dropped the same way the callback path
drops it.
The SSO page opens through Hex.Utils.system_open/1 like hex.docs does,
so the test stub and WSL fallback apply and the hex_core passthrough is
gone. Deauth clears the local token once, revocation is remote-only,
the repo token expiry check reuses the vendored predicate instead of
restating the 300-second buffer, Hex.OAuth.get_token/0 and the unused
auth opts go, and the API-key notice no longer names an env var the key
may not have come from.
hex_core revendored at eb5508a.
hex.config holds the OAuth access and refresh tokens and was written
with no mode, so under the default umask it landed world readable and
any local user could mint access tokens from the refresh token. It is
written 0600 in a 0700 home.
The Windows opener dropped the empty title argument that start expects
and escaped only &, while the SSO verification URI it now opens comes
from the server. It passes the title and escapes what cmd.exe acts on.
Config writes were an unlocked read-merge-write of the whole file, and
the two callers hold different locks in hex_core, so a repository token
written concurrently with a global one could drop it. Every write takes
one lock.
With HEX_API_KEY set, SSO renewal was refused for a reason that is not
true: repository requests never use the API key, they fall through to
the stored session, so those fetches kept a lapsed session with no way
to renew it. The re-authorization request resolves the session
explicitly and the refusal is gone.
The authentication preflight passed auth_inline with optional, which
cannot prompt, and swallowed its own error. It prompts, and it says what
happened when it cannot. It also ran before the offline check, so an
offline resolution could still issue a refresh.
The SSO check walked every prefetch and looked up every repository
before testing a flag list that is empty for anyone with nothing
lapsed. The organizations are computed once for both callers and the
flag is tested first.
mix hex.user deauth discarded the revocation result and reported success
regardless, so a token that was never revoked server side looked
revoked. It warns.
Repository fetches that end without credentials now report what happened
rather than an inspected tuple, since that is the ordinary shape once
the 401 path stops prompting.
Write hex.config through a temporary file that is created empty, chmodded
to 0600, and renamed over the target. The tokens were written in place and
the mode applied afterwards, so they sat readable for the length of the
write, and a reader could see a truncated file. A failed chmod now fails
the write instead of being discarded.
Keep the existing session until the new one arrives. mix hex.user auth
revoked and cleared the stored credentials before starting the device flow,
so a denied, timed out, or interrupted authentication left the user with
nothing.
Resolve the resolution preflight through the stored session rather than
HEX_API_KEY. The fetches it runs ahead of use the session, so an API key
resolved here and left the session to be refreshed mid-fetch, past the
point where the SSO prompt can be offered.
Escape %% for cmd.exe, which expands %%NAME%% before it looks for command
separators.
Strip control characters from server-supplied verification URLs and device
codes before printing them, so a response cannot rewrite the terminal.
Persist a repository token inside the config transaction. The repository
map was read outside it and written whole, so two parallel exchanges in one
run could drop one of the two tokens.
Treat end of input as a cancelled OTP prompt. Mix.shell().prompt returns
:eof rather than nil, so a challenge with no tty raised instead of
returning the auth error.
`do_write/1` only chmodded the config directory when it had just created it, so
an install predating the private-write change stays world-readable. Mine is
`drwxr-xr-x` right now. The 0600 on the file is no help if anyone can list the
directory and open what is in it.
Revendors hex_core for the refused-refresh prompt: `with_api/4` consulted
`should_authenticate` only when there were no credentials at all, so a session
the server refused produced three "run mix hex.user auth" messages in one
`mix deps.get` and no prompt.
`update_repo/2` read `$repos` from the config file and wrote the result to both
the file and `Hex.State`, so any repository the file does not have was replaced
the moment any repository exchanged an API key for an OAuth token. `HEX_REPOS`,
`HEX_MIRROR` and a caller that set `:repos` directly all land in state without
ever reaching the file, so the next request went wherever the file said.
The two halves start from different places now. The file is what another
process may have written since, so the disk write still starts from the file,
which is what the lock is for. The state write starts from the state.
This is what made `Hex.RepoTest`'s OAuth cache tests fail depending on test
order: they set the hexpm URL to the local test server in state only, and the
first exchange replaced it with the real repo.hex.pm from the config file.
Reproduced with `mix test --seed 24390` before the fix.
The server's refusal no longer names a client command, since it cannot
know which client asked. A full mix hex.user auth re-establishes
organization access at approval, so it is the fallback whatever kept
the in-place flow from starting.

@maennchenmaennchen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me besides the nit.

Comment threadlib/hex/remote_converger.ex Outdated
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")
Hex.Shell.warn(
"Could not start SSO authentication: #{message}. " <>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably send this through Hex.Util.escape_terminal to be safe.

@ericmj
ericmj merged commit 5fa3c9e into mainSep 1, 2026
22 checks passed
@ericmj
ericmj deleted the organization-sso-reauth branch September 1, 2026 10:58
Sign up for freeto 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

@ericmj@maennchen