From fe236b3dd1b4358352fc1f9efd5d5b5515aa11fd Mon Sep 17 00:00:00 2001 From: Michael Gasperini <163041857+TheStreamCode@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:46:41 +0200 Subject: [PATCH] fix(auth): keep polling through transient token-endpoint errors pollToken treated a rejected fetch or an unparseable token response as a terminal failure and aborted the device login with "unreadable token response" on the first network blip. Classify transport and parse failures separately, keep polling with the existing interval until the device code expires, and abort only on terminal OAuth errors. The timeout message now reports how many network errors occurred. --- CHANGELOG.md | 8 ++++++ src/auth.ts | 30 ++++++++++++++------- tests/auth.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0adfa..f4cdc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Device login keeps polling through transient network failures and unreadable token responses until + the device code expires, instead of aborting on the first blip. Terminal OAuth errors still stop + the flow immediately. + ## [0.4.0] - 2026-10-21 ### Added diff --git a/src/auth.ts b/src/auth.ts index 96289e0..0b90ffe 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -160,9 +160,12 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn let interval = Math.max(MIN_INTERVAL_S, Number(intervalS) || 5) const deadline = Date.now() + Number(expiresInS || 600) * 1000 let slowDowns = 0 + let transportFailures = 0 while (Date.now() < deadline) { - // The token endpoint answers HTTP errors (e.g. 400 authorization_pending) - // as part of the normal flow: never throw here, classify instead. + // The token endpoint answers OAuth errors (e.g. 400 authorization_pending) as part of the + // normal flow. A rejected fetch, an unparseable body, or a body carrying neither a token nor an + // OAuth error is a transport/proxy blip, not a verdict: count it and keep polling until the + // deadline. Only an explicit, terminal OAuth error aborts the device login. let body: TokenResponse | null = null try { const res = await fetch(TOKEN_URL, { @@ -183,18 +186,23 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn } catch { body = null } - if (body && !body.error) { - if (typeof body.access_token !== "string" || !body.access_token) { - throw new Error("token response has no access_token") - } + + if (body && typeof body.access_token === "string" && body.access_token) { return body.access_token } + if (body?.error === "slow_down") { slowDowns += 1 interval = Math.max(MIN_INTERVAL_S, interval + SLOW_DOWN_STEP_S) - } else if (body?.error !== "authorization_pending") { - throw new Error(`login failed: ${body?.error || "unreadable token response"}`) + } else if (body?.error === "authorization_pending") { + // Expected while the user completes approval: keep polling at the current interval. + } else if (typeof body?.error === "string" && body.error) { + throw new Error(`login failed: ${body.error}`) + } else { + // Unreadable response: no body, no token, and no OAuth error to classify. + transportFailures += 1 } + const remaining = deadline - Date.now() if (remaining <= 0) break const { promise, resolve } = Promise.withResolvers() @@ -202,7 +210,11 @@ export async function pollToken(deviceCode: string, intervalS: number, expiresIn await promise } throw new Error( - slowDowns ? "login timed out after slow_down responses" : "login timed out waiting for approval", + slowDowns + ? "login timed out after slow_down responses" + : transportFailures > 0 + ? `login timed out waiting for approval after ${transportFailures} network error${transportFailures === 1 ? "" : "s"}` + : "login timed out waiting for approval", ) } diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 678a24a..406301f 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -91,6 +91,72 @@ test("poll rejects terminal errors", async () => { await assert.rejects(pollToken("dc", 0, 600), /access_denied/) }) +test("poll survives transient transport failures", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + if (calls <= 2) { + throw new TypeError("fetch failed") + } + return calls === 3 + ? new Response(JSON.stringify({ error: "authorization_pending" }), { status: 400 }) + : new Response(JSON.stringify({ access_token: "dca-after-blip" })) + }) as typeof fetch + try { + assert.equal(await pollToken("dc", 1, 600), "dca-after-blip") + assert.equal(calls, 4) + } finally { + globalThis.fetch = original + } +}) + +test("poll keeps polling after unparseable token responses", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return calls === 1 + ? new Response("bad gateway", { status: 502 }) + : new Response(JSON.stringify({ access_token: "dca-after-html" })) + }) as typeof fetch + try { + assert.equal(await pollToken("dc", 1, 600), "dca-after-html") + assert.equal(calls, 2) + } finally { + globalThis.fetch = original + } +}) + +test("poll still aborts on terminal OAuth errors after a transport blip", async () => { + const original = globalThis.fetch + let calls = 0 + globalThis.fetch = (async () => { + calls += 1 + return calls === 1 + ? Promise.reject(new TypeError("fetch failed")) + : new Response(JSON.stringify({ error: "access_denied" }), { status: 400 }) + }) as typeof fetch + try { + await assert.rejects(pollToken("dc", 0, 600), /access_denied/) + assert.equal(calls, 2) + } finally { + globalThis.fetch = original + } +}) + +test("poll reports transport failures when it times out", async () => { + const original = globalThis.fetch + globalThis.fetch = (async () => { + throw new TypeError("fetch failed") + }) as typeof fetch + try { + await assert.rejects(pollToken("dc", 1, 1), /1 network error/) + } finally { + globalThis.fetch = original + } +}) + test("mint validates subscription", async () => { stubFetch(() => ({ status: 200, body: KEY_OK })) const minted = await mintKey("dca-x")