From 34912887fe10986c827927e17331fb2e9008e5c4 Mon Sep 17 00:00:00 2001 From: coela <147021942+coela-oss@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:06:35 +0900 Subject: [PATCH] Fix device flow polling state and add read-only test CI --- .github/workflows/ci.yml | 20 ++++++++++++++++++++ README.md | 9 +++++++++ src/device-flow.mjs | 12 +++++++----- src/index.d.ts | 3 ++- test/contracts.test.mjs | 35 ++++++++++++++++++++++++++++++++++- 5 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..890bd15 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI +on: + push: + branches: [main] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '24' + - run: node --test test/*.test.mjs diff --git a/README.md b/README.md index e9c1b18..d8ae979 100644 --- a/README.md +++ b/README.md @@ -30,3 +30,12 @@ Apache-2.0; see LICENSE and NOTICE. Earlier MIT attribution remains in LICENSE-M The package is an independently consumable unit. Callers reference its documented interface through a versioned dependency and own application-specific composition and integration. + +## Continuing device authorization + +When polling returns `status: 'pending'`, replace the previous attempt with +`result.attempt` and wait until `result.nextPollUnixMs` before polling again. +The updated attempt preserves the cumulative five-second increase for each +`slow_down` response. Attempts contain a device code: keep them local and do +not log, publish, or include them in public reports. Serialize polling for each +attempt; this stateless package cannot prevent reuse of an older attempt. diff --git a/src/device-flow.mjs b/src/device-flow.mjs index f48ebcb..7535da5 100644 --- a/src/device-flow.mjs +++ b/src/device-flow.mjs @@ -33,7 +33,7 @@ export function acceptGitHubDeviceCodeResponse(declaration, input) { export async function pollGitHubDeviceAuthorization(attempt, input, transport, custody) { validateAttempt(attempt) const now = integer(input?.nowUnixMs, 0, 'time') - if (now > attempt.expiresAtUnixMs) invalid('attempt-expired') + if (now >= attempt.expiresAtUnixMs) invalid('attempt-expired') if (now < attempt.nextPollUnixMs) invalid('poll-early') const response = await send(transport, tokenUrl, form({ client_id: attempt.clientId, device_code: attempt.deviceCode, @@ -66,10 +66,12 @@ export async function completeGitHubDeviceAuthorization( } function pending(attempt, wire, now) { - if (wire.error === 'authorization_pending') return freeze({ status: 'pending', - nextPollUnixMs: now + attempt.intervalSeconds * 1000 }) - if (wire.error === 'slow_down') return freeze({ status: 'pending', - nextPollUnixMs: now + (attempt.intervalSeconds + 5) * 1000 }) + if (wire.error === 'authorization_pending' || wire.error === 'slow_down') { + const intervalSeconds = attempt.intervalSeconds + (wire.error === 'slow_down' ? 5 : 0) + const nextPollUnixMs = now + intervalSeconds * 1000 + return freeze({ status: 'pending', nextPollUnixMs, + attempt: { ...attempt, intervalSeconds, nextPollUnixMs } }) + } if (['access_denied', 'expired_token', 'incorrect_device_code'].includes(wire.error)) { invalid(wire.error) } diff --git a/src/index.d.ts b/src/index.d.ts index 8ab1e19..be3f97e 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -66,7 +66,8 @@ export function acceptGitHubDeviceCodeResponse(declaration: GitHubAuthDeclaratio response: { status: number, body: string } }): GitHubDeviceAttempt export function pollGitHubDeviceAuthorization(attempt: GitHubDeviceAttempt, input: { nowUnixMs: number }, transport: AuthTransport, - custody: GitHubTokenCustody): Promise<{ status: 'pending', nextPollUnixMs: number } + custody: GitHubTokenCustody): Promise<{ status: 'pending', nextPollUnixMs: number, + attempt: GitHubDeviceAttempt } | { status: 'user-verification-required', authorization: GitHubPasskeyAuthorization }> export function completeGitHubDeviceAuthorization( authorization: GitHubPasskeyAuthorization, assertion: GitHubPasskeyAssertion, diff --git a/test/contracts.test.mjs b/test/contracts.test.mjs index 4695b2f..b08128e 100644 --- a/test/contracts.test.mjs +++ b/test/contracts.test.mjs @@ -75,7 +75,9 @@ test('keeps pending responses bounded and rejects provider substitution', async assert.equal(request.url, 'https://github.com/login/oauth/access_token') return { status: 200, body: JSON.stringify({ error: 'authorization_pending' }) } } }, {}) - assert.deepEqual(pending, { status: 'pending', nextPollUnixMs: 11_000 }) + assert.equal(pending.status, 'pending') + assert.equal(pending.nextPollUnixMs, 11_000) + assert.equal(pending.attempt.nextPollUnixMs, 11_000) await assert.rejects(() => requestGitHubDeviceCode(declaration, { clientId: '../unsafe', nowUnixMs: 1 }, {}), /client-id-invalid/u) }) @@ -103,3 +105,34 @@ test('deletion scope is explicit and unexpected scope elevation never enters cus } assert.equal(entered, 0) }) + +test('carries cumulative slow-down and pending deadlines into subsequent polls', async () => { + let attempt = await requestGitHubDeviceCode(declaration, + { clientId: 'Iv23Public', nowUnixMs: 1_000 }, { + async send() { return { status: 200, body: deviceWire } } + }) + let calls = 0 + const responses = ['slow_down', 'slow_down', 'authorization_pending'] + const transport = { async send() { + calls++ + return { status: 200, body: JSON.stringify({ error: responses.shift() }) } + } } + for (const [now, interval, next] of [ + [6_000, 10, 16_000], [16_000, 15, 31_000], [31_000, 15, 46_000] + ]) { + const result = await pollGitHubDeviceAuthorization(attempt, + { nowUnixMs: now }, transport, {}) + assert.equal(result.attempt.intervalSeconds, interval) + assert.equal(result.attempt.nextPollUnixMs, next) + assert.equal(Object.isFrozen(result.attempt), true) + attempt = result.attempt + const count = calls + await assert.rejects(() => pollGitHubDeviceAuthorization(attempt, + { nowUnixMs: next - 1 }, transport, {}), /poll-early/u) + assert.equal(calls, count) + } + const count = calls + await assert.rejects(() => pollGitHubDeviceAuthorization(attempt, + { nowUnixMs: attempt.expiresAtUnixMs }, transport, {}), /attempt-expired/u) + assert.equal(calls, count) +})