Skip to content

fix(cloud): build login url from the bound auth listener port - #10516

Merged
luvkapur merged 4 commits into
masterfrom
fix/cloud-login-auth-listener-port
Jul 22, 2026
Merged

fix(cloud): build login url from the bound auth listener port#10516
luvkapur merged 4 commits into
masterfrom
fix/cloud-login-auth-listener-port

Conversation

@luvkapur

@luvkapur luvkapur commented Jul 22, 2026

Copy link
Copy Markdown
Member

Problem

bit login starts a local callback server and then hands the browser a url telling it which port to post the token back to. When that port is already taken, the two silently diverge and login never completes.

setupAuthListener registers its map entry optimistically — before listen() runs:

this.authListenerByPort.set(port, { port, clientId });   // set before listen()
const authServer = expressApp.listen(port, () => { ... })
  .on('error', (err) => {
    if (code === 'EADDRINUSE') {
      this.setupAuthListener({ port: port + 1 })          // retry one port up
        .then(resolve).catch(reject);

If listen() then fails with EADDRINUSE, the retry binds port + 1, but the stale, server-less entry for the original port is never removed. getLoginUrl afterwards recomputes the port from the requested value rather than the one that was actually bound, finds that stale entry, and advertises it:

const port = Number(portFromParams) || this.getLoginPort();
const authListenerForPort = this.authListenerByPort.get(port);   // resolves the stale entry
if (authListenerForPort) {
  return `${loginUrl}?port=${port}&clientId=${authListenerForPort.clientId}&...`;
}

The same defect exists in getLoginUrl's other branch — when no listener is registered yet for the requested port, it pairs the requested port with the bound listener's clientId:

const authListener = await this.setupAuthListener({ port });
return encodeURI(`${loginUrl}?port=${port}&clientId=${authListener?.clientId}&...`);

That is the path the workspace ui login link takes, via the graphql loginUrl query.

Either way the browser posts the token to a port with no bit server behind it. The token is lost, the callback never fires, and bit login hangs indefinitely — from the user's side it looks like the login page did nothing.

The default login port is 8889 (new LoginCmd(cloudMain, 8889)), so this reproduces on any machine already running something there. Note CloudMain.DEFAULT_PORT = 8888 is only the fallback for the workspace's own setupAuthListener() — it is not the bit login default.

Repro — occupy the login port, then log in:

node -e "require('http').createServer((_,r)=>r.end('squat')).listen(8889,()=>{})" &
bit login --no-browser
# url advertises  port=8889
# bit listens on  8890

A second, quieter bug in the same path: the EADDRINUSE retry drops clientId, skipConfigUpdate and cloudDomain. A bumped listener therefore rewrites .npmrc even when the user passed --skip-config-update, and re-mints a clientId that no longer matches the one advertised.

Unencoded url params

deviceName is interpolated raw from --machine-name or os.hostname(). A machine named home & away #1 yields a url that is not valid at all:

...&responseType=token&deviceName=home & away #1 café+wifi&os=darwin

The # turns everything after it into a fragment, so os is silently dropped; the & forges a bogus param; and the raw spaces break the url when handed to open() or copy-pasted. encodeURI() — used on one of the two branches — cannot fix this either, since it leaves &, # and + intact.

Fix

  • build the login url from the port the auth listener actually bound to, instead of the requested one — in login() (cli) and in getLoginUrl's setup branch (graphql / workspace ui). in the cli path this corrects the advertised clientId too, since it now resolves the real listener's entry
  • delete the stale, server-less entry on the EADDRINUSE retry, so no caller can resolve a port with nothing behind it
  • forward clientId / skipConfigUpdate / cloudDomain to the retried listener
  • percent-encode every url param with encodeURIComponent, and route both getLoginUrl branches through a single buildLoginUrl helper so the encoding and the "advertise the bound port" invariant cannot drift apart again

No behavior change when the requested port is free.

Verification

Built from source and exercised end to end with bit login --no-browser, simulating the browser callback with curl against the port the url advertises. Identical test, both builds:

before after
url advertises port=8889 port=8890
bit actually listening on 8890 8890
token post to advertised port hits the squatter → 'squat' bit → 200 Login successful
bit login outcome hangs, never completes ✔ Logged in as <user>

Workspace ui path, via bit start with 8888 occupied and the {loginUrl} graphql query:

before after
loginUrl advertises port=8888 port=8889
bit start listening on 8889 8889

Also verified:

  • explicit --port 8085 with 8085 occupied — advertised 8085 / bound 8086 before, both 8086 after
  • port free (no bump) — advertised port matches bound port, unchanged from today
  • --machine-name 'home & away #1 café+wifi' — before: url truncated at the space, deviceName came back as home, os lost entirely. after: deviceName=home%20%26%20away%20%231%20caf%C3%A9%2Bwifi, round-trips byte-for-byte, os intact, no fragment
  • instrumented run confirms the stale entry is the culprit: mapKeys=[8889,8890], with getLoginUrl resolving the server-less 8889

Note on #10225

Refs #10225 — not confirmed to be the same bug, so this intentionally does not auto-close it. That report describes the login button not being clickable, which is a render-side symptom on the bit.dev/bit-login page; the failure fixed here happens after the click. Both present as "login does nothing", so this is worth ruling in or out with the reporter before closing the issue.

🤖 Generated with Claude Code

luvkapur and others added 3 commits July 22, 2026 10:25
when the login port is already in use, setupAuthListener falls back to the
next free port, but getLoginUrl rebuilt the url from the requested port and
resolved a stale, server-less map entry. the browser was told to post the
token to a port with no server behind it, so the token never arrived and
bit login hung indefinitely.

thread the port the listener actually bound to into getLoginUrl, drop the
stale entry on the EADDRINUSE retry, and forward clientId/skipConfigUpdate/
cloudDomain to the retried listener (previously dropped on every bump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getLoginUrl's second branch (no listener registered yet for the requested
port) paired the requested port with the bound listener's clientId, so the
url pointed at a port with nothing behind it whenever setupAuthListener
bumped. this is the path the workspace ui login link takes via the graphql
loginUrl query.

verified with 8888 occupied: `{loginUrl}` advertised 8888 while bit start
listened on 8889; now advertises 8889.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deviceName is interpolated raw from --machine-name or os.hostname(), so a
machine named `home & away #1` produced an invalid url: the '#' turned the
rest into a fragment (dropping os), the '&' forged a bogus param, and raw
spaces broke the url when handed to open() or copy-pasted.

encodeURI() could not fix this — it leaves '&', '#' and '+' intact. encode
each value with encodeURIComponent instead, and route both getLoginUrl
branches through a single buildLoginUrl helper so the encoding and the
"advertise the bound port" invariant cannot drift apart again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@luvkapur
luvkapur marked this pull request as ready for review July 22, 2026 19:20
@luvkapur
luvkapur enabled auto-merge (squash) July 22, 2026 19:21
@luvkapur
luvkapur merged commit 1902d98 into master Jul 22, 2026
13 checks passed
@luvkapur
luvkapur deleted the fix/cloud-login-auth-listener-port branch July 22, 2026 19:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants