Skip to content

fix(electron): the loopback must not report a sign-in it dropped - #205

Merged
yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/auth-handoff-honest-status
Sep 5, 2026
Merged

yetone merged 1 commit into
yetone:mainfrom
WhichPaths:fix/auth-handoff-honest-status

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

The browser says "Signed in ✓" while the desktop app received nothing.

The return page hands the token to the loopback and reports success straight from the HTTP status:

fetch('/auth/token', { method: 'POST',}).then((r) => {
  if (!r.ok) throw new Error('handoff rejected: ' + r.status);
  h1.textContent = 'Signed in';
  sub.textContent = 'Cumora has your session.';
  label.innerHTML = '<span class="ok">✓</span> Signed in';
  btn.disabled = true;
  hint.textContent = 'You can close this tab.';
}).catch(() => { /* …offer the OS route… */ })

dispatchAuthToken drops a token whose nonce does not match — that is the drive-by-deep-link defence, and it is right — but it returned void:

function dispatchAuthToken(token, companyId, nonce) {
  if (!consumeAuthNonce(nonce)) {
    console.warn('[auth] dropped inbound token: no matching armed nonce (possible drive-by deep link)')
    return                                     // ← caller cannot tell
  }

and the handler answered 204 either way:

dispatchAuthToken(parsed.token, companyId, nonce)
res.statusCode = 204; res.end()                // ← unconditional

So the drop is silent in the one place the user is looking. The page's catch branch — the one that tells them to open Cumora themselves — is unreachable for exactly the case it was written for.

No attacker needed

armAuthHandoff's own comment says it "supersedes any previous unused nonce", and AuthScreen re-arms on window focus. So:

  1. Click "Sign in with Google". The browser tab opens behind the app.
  2. Click back into the Cumora window — the focus handler re-arms, buttons go live again.
  3. Click sign-in again. A second nonce supersedes the first.
  4. Finish the consent in the tab that is already open — the first one, carrying the now-stale nonce.

Token dropped, page says signed in, user closes the tab.

The recovery failed too

consumeAuthNonce cleared the armed nonce before validating it:

const armed = armedAuthNonce
const expiry = armedAuthExpiry
armedAuthNonce = null          // ← unconditional
armedAuthExpiry = 0
if (!armed || Date.now() > expiry) return false

So the stale tab's POST disarmed the live nonce on its way out. Going back and finishing the second tab — the obvious thing to try — then failed as well, with the same false success.

Now it clears only on a match, or on a genuine expiry. The timing-safe compare and the length pre-check are untouched, and a test pins them so this cannot quietly loosen the comparison it guards.

The change

  • dispatchAuthToken returns whether it accepted.
  • The handler answers 409 when it did not, so the page falls into its existing catch and shows "Ready when you are" with the "Open Cumora" route.
  • consumeAuthNonce consumes on match.

The cumora:// deep-link handler ignores the new return value deliberately — there is nobody to answer there.

Verification

electron/ has no runtime harness (main.cjs requires electron), so this is a source-level guard in the shape of electron-tray-unread-dot.test.ts. Eight cases; three go red against the shipped file:

not ok 1 - the loopback answers what actually happened
not ok 2 - dispatchAuthToken reports acceptance on both paths
not ok 3 - a mismatching nonce does not disarm the pending sign-in
# pass 5  # fail 3

Both matchers self-test in both directions — reject the shipped shape, accept the fixed one — because a source-scraping check's failure mode is becoming a silent no-op. The anchors fail loudly if the functions move rather than passing vacuously.

One case is purely a guard on the change itself: crypto.timingSafeEqual and the length check must still be how the nonce is compared.

biome lint . clean; unit suite 1110 pass / 0 fail.

I could not exercise a real OAuth round trip here, so the flow above is argued from the control flow and the page's own script rather than observed end to end.

The return page POSTs the token back to the desktop loopback and reports
success from the HTTP status:

  if (!r.ok) throw new Error('handoff rejected: ' + r.status);
  h1.textContent = 'Signed in';
  label.innerHTML = '<span class="ok">✓</span> Signed in';

dispatchAuthToken drops a token whose nonce does not match — that is the
drive-by-deep-link defence and it is right — but it returned void, and
the handler answered 204 regardless. So the browser told the user they
were signed in, with a checkmark and the button greyed out, while the app
had received nothing. The page's catch branch, the one that says to open
Cumora itself, was unreachable for this case.

Reaching it takes no attacker. armAuthHandoff "supersedes any previous
unused nonce", and AuthScreen re-arms on window focus: click sign-in, the
browser opens behind, click back into Cumora, click sign-in again, then
finish the FIRST tab. Its nonce is now stale.

Second half: consumeAuthNonce cleared the armed nonce before validating,
so that stale tab also disarmed the live one — the obvious recovery,
going back to finish the other tab, failed too. Clear only on a match, or
on a real expiry. The timing-safe compare and the length check are
unchanged, and a test pins them so this cannot quietly loosen the
comparison it guards.

The deep-link handler ignores the new return value on purpose: there is
nobody to answer there.

Three of the eight cases go red against the shipped file, and the
matchers self-test both ways — a source-scraping guard's failure mode is
becoming a silent no-op.
@yetone
yetone merged commit 1ebccbb into yetone:main Sep 5, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 5, 2026
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