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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 7 additions & 5 deletions src/device-flow.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
3 changes: 2 additions & 1 deletion src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 34 additions & 1 deletion test/contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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)
})
Loading