Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -183,26 +186,35 @@ 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<void>()
setTimeout(resolve, Math.min(interval, remaining) * 1000)
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",
)
}

Expand Down
66 changes: 66 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html>bad gateway</html>", { 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")
Expand Down
Loading