Skip to content

πŸ› fix(dashboard): send flow_id from the in-page GitHub sign-in overlay - #6217

Open
Danathar wants to merge 1 commit into
hivecommons:v4from
Danathar:fix/6216-dashboard-gh-login-flow-id
Open

πŸ› fix(dashboard): send flow_id from the in-page GitHub sign-in overlay#6217
Danathar wants to merge 1 commit into
hivecommons:v4from
Danathar:fix/6216-dashboard-gh-login-flow-id

Conversation

@Danathar

@Danathar Danathar commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Sign in with GitHub on the dashboard works again.

You clicked it, pasted the code at github.com/login/device, GitHub said the
device was activated β€” and the overlay sat on Waiting for authorization...
forever. No error, no session, just a spinner.

Every one of those polls was being refused with 400. #5748 started requiring a
flow_id on /api/gh-user-auth/poll and taught the standalone login page and
hivectl login to send it, but the dashboard's own in-page overlay is a
second implementation of the same flow and was missed. It kept posting an
empty body, so it could never complete.

This makes the overlay send the flow_id, and stop with a visible error when it
gets an answer it does not understand.

Closes #6216.

Why it broke, and why nobody noticed

Two separate things went wrong, and the second is what turned a bug into a
mystery.

It was rejected. startGHLogin() read user_code, verification_uri and
interval out of the /start response and dropped flow_id on the floor.
pollGHAuth() then polled with no body at all:

const resp = await fetch('/api/gh-user-auth/poll', { method: 'POST' });

The server compares the presented flow_id against the one it minted and
answers 400 before contacting GitHub or setting any cookie. That binding is
deliberate and load-bearing β€” the session cookie is minted on the poll
response and both routes are public, so a poll that cannot prove it started the
flow must get nothing, or an anonymous poller could race the operator for a
freshly approved session (#5742). The overlay simply never presented it.

It was silent. jsonError writes {"ok":false,"error":"…"} β€” no status
field. pollGHAuth branched only on complete, slow_down and error, so the
400 body matched none of them, the function returned without touching the timer,
and setInterval fired again. An eternal spinner instead of a message.

It survived a release because the two implementations are reached by
disjoint sets of users. authenticate() serves the standalone login page only
for an untrusted non-/api request; an open hive, or one already carrying
HIVE_DASHBOARD_TOKEN, gets index.html, where the overlay is the only sign-in
path available. The login page β€” which threads flow_id correctly β€” was never
broken, and its tests stayed green from the 2026-09-02 merge through v4.18.1.

The fix

  • startGHLogin() keeps data.flow_id in a module-scoped ghFlowID, and
    pollGHAuth() sends {"flow_id": ghFlowID} with a JSON content type β€” the
    same contract the login page already has. Cleared on success, on a terminal
    failure and on cancel, so an abandoned flow's secret is not carried into
    whatever the operator does next.
  • A terminal branch: !resp.ok, or an error with no recognised status, now
    stops the timer and paints the message. Both that path and the server's own
    {"status":"error"} route through one shared failGHAuth(), so they cannot
    drift apart. An explicit pending branch documents the steady state that
    should keep polling, so the fallback can never swallow it.

The global window.fetch wrapper mutates opts in place to add an
Authorization header and passes the same object through, so the new body and
Content-Type survive it β€” including its one 401/403 retry, which shallow-copies
opts.

Testing

The interesting part of this fix is the regression guard, because the
server-side tests could not have caught it
. There, the browser is the thing
being correctly rejected β€” TestCovDF_GHUserAuthPoll_MissingFlowIDRejected even
names "a pre-binding client" as the caller it turns away, and that caller was
this overlay. Only an assertion on the client catches this direction.

Four client assertions, scoped to the relevant function bodies rather than the
whole file:

  • TestGHLoginOverlayPollSendsFlowID β€” the poll carries flow_id, and carries
    ghFlowID rather than some constant.
  • TestGHLoginOverlayCapturesFlowIDFromStart β€” startGHLogin() actually stores
    data.flow_id.
  • TestGHLoginOverlayStopsOnUnrecognizedResponse β€” the poll inspects resp.ok
    and routes terminal answers through failGHAuth.
  • TestGHLoginOverlayFailHelperClearsTimerAndShowsMessage β€” that helper really
    does clear the interval and write to the overlay.

All four fail against a pristine upstream/v4 and pass on this branch β€” I
replayed each assertion's exact slice bounds and predicates against the
unmodified file to confirm the guard would have caught #5748's miss rather than
merely describing it.

One more test closes the loop end to end. TestGHUserAuthPollAcceptsBrowserBodyShape
starts a flow through the public handler and polls with the literal bytes
JSON.stringify({flow_id: ghFlowID}) now produces, as an anonymous browser with
no role headers. Every other poll test marshals a Go map, so none of them pinned
the wire shape the page actually emits β€” and #6216 was precisely a wire-shape
mismatch that survived because the client and server were only ever tested apart.

pkg/dashboard passes in full, and golangci-lint reports 0 issues for it.

One flaky test to declare: TestLeaseRestart_ResumeSurvivesHubRestart (contribute
leases, untouched by this change) fails intermittently. I measured it β€” 2
failures in 10 runs on a clean upstream/v4 worktree, 1 in 10 on this branch

β€” so it is a pre-existing flake, not a regression here. Worth its own issue.

Deliberately not in scope

The issue notes that startCopilotLogin and the Claude login flow are the same
shape of unbound public poll. They have no flow_id binding server-side at
all
, so they are unaffected by this bug β€” binding them is new hardening, not
this regression, and changing those auth paths under a bugfix PR would be the
wrong place to review it.

β€” hive: backend=claude model=claude-opus-5

hivecommons#5748 made `flow_id` mandatory on /api/gh-user-auth/poll β€” the binding that
stops an anonymous poller racing the operator for a freshly approved session,
since the cookie is minted on the poll response and both routes are public. It
taught the standalone login page and `hivectl login` to send it and missed the
overlay in static/index.html, which is a second, separate implementation of the
same flow.

So the overlay kept POSTing an empty body. Every poll was refused 400 before
GitHub was contacted, and the login could never complete: GitHub reported the
device activated while the dashboard sat on "Waiting for authorization..."
indefinitely.

It failed SILENTLY rather than erroring because jsonError writes
{"ok":false,"error":"..."} with no `status` field. pollGHAuth branched only on
complete / slow_down / error, so the 400 body matched none of them, the function
returned without touching the timer, and setInterval fired again β€” an eternal
spinner instead of a message. The standalone login page already guards this
("Any other shape is terminal"); the overlay did not.

Why it survived a release: authenticate() serves the login page only for an
UNTRUSTED non-/api request. An open hive, or one already carrying
HIVE_DASHBOARD_TOKEN, gets index.html, where the overlay is the only sign-in
path. The two implementations are reached by disjoint sets of users, so the
login page's tests stayed green throughout.

  - startGHLogin() keeps data.flow_id in ghFlowID, and the poll sends
    {"flow_id": ghFlowID} with a JSON content type β€” the same contract as the
    login page. Cleared on success, on a terminal failure, and on cancel, so an
    abandoned flow's secret is not carried into the next thing the operator does.
  - A terminal branch: !resp.ok or an `error` with no recognised `status` now
    stops the timer and paints the message, via a shared failGHAuth() the
    server's own error path also routes through so the two cannot drift.

Tests. The regression guard asserts the PAGE carries the id β€” server-side tests
cannot catch this direction, because there the browser is the thing being
correctly rejected (TestCovDF_GHUserAuthPoll_MissingFlowIDRejected even names
"a pre-binding client" as the caller it turns away). All four client assertions
fail against upstream/v4 and pass here. One more test closes the loop end to
end: it starts a flow through the public handler and polls with the literal
`{"flow_id":"..."}` bytes JSON.stringify now produces, as an anonymous browser β€”
every other poll test marshals a Go map, so none of them pinned the wire shape
the page actually emits, and hivecommons#6216 was exactly a wire-shape mismatch.

Deliberately not touched: startCopilotLogin and the Claude login flow are the
same shape of unbound public poll, but they have no flow_id binding server-side
at all, so they are unaffected by this bug. Binding them is hardening, not this
regression, and belongs in its own change.

Closes hivecommons#6216

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UQTim25GU8Yk2HrCh39i1u
Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
@kubestellar-prow kubestellar-prow Bot added the dco-signoff: yes Indicates the PR's author has signed the DCO. label Sep 7, 2026
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign hanthor for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Sep 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

πŸ› bug: dashboard "Sign in with GitHub" never completes β€” the in-page overlay polls without the flow_id required since #5748

1 participant