fix: preserve poll abort signals on requests

This commit is contained in:
Filip Skokan
2026-04-27 18:12:10 +02:00
parent baff023379
commit 17ff77de08
2 changed files with 220 additions and 2 deletions
+44 -2
View File
@@ -2161,6 +2161,44 @@ function wait(duration: number, signal: AbortSignal): Promise<void> {
})
}
function pollRequestSignal(
pollingSignal: AbortSignal,
timeout?: number,
): { signal: AbortSignal; cleanup(): void } {
const timeoutSignal = signal(timeout)
if (!timeoutSignal) {
return {
signal: pollingSignal,
cleanup() {},
}
}
const controller = new AbortController()
const abort = (event: Event) => {
const source = event.target as AbortSignal
controller.abort(source.reason)
}
if (pollingSignal.aborted) {
controller.abort(pollingSignal.reason)
} else if (timeoutSignal.aborted) {
controller.abort(timeoutSignal.reason)
} else {
pollingSignal.addEventListener('abort', abort, { once: true })
timeoutSignal.addEventListener('abort', abort, { once: true })
}
return {
signal: controller.signal,
cleanup() {
pollingSignal.removeEventListener('abort', abort)
timeoutSignal.removeEventListener('abort', abort)
},
}
}
/**
* Continuously polls the {@link ServerMetadata.token_endpoint token endpoint}
* until the end-user finishes the {@link !"Device Authorization Grant"} process
@@ -2238,6 +2276,7 @@ export async function pollDeviceAuthorizationGrant(
},
)
const requestSignal = pollRequestSignal(pollingSignal, timeout)
const response = await oauth
.deviceCodeGrantRequest(
as,
@@ -2250,10 +2289,11 @@ export async function pollDeviceAuthorizationGrant(
additionalParameters: parameters,
DPoP: options?.DPoP,
headers: new Headers(headers),
signal: pollingSignal.aborted ? pollingSignal : signal(timeout),
signal: requestSignal.signal,
},
)
.catch(errorHandler)
.finally(requestSignal.cleanup)
if (response.status === 503 && response.headers.has('retry-after')) {
await handleRetryAfter(response, interval, pollingSignal, true)
@@ -2480,6 +2520,7 @@ export async function pollBackchannelAuthenticationGrant(
},
)
const requestSignal = pollRequestSignal(pollingSignal, timeout)
const response = await oauth
.backchannelAuthenticationGrantRequest(
as,
@@ -2492,10 +2533,11 @@ export async function pollBackchannelAuthenticationGrant(
additionalParameters: parameters,
DPoP: options?.DPoP,
headers: new Headers(headers),
signal: pollingSignal.aborted ? pollingSignal : signal(timeout),
signal: requestSignal.signal,
},
)
.catch(errorHandler)
.finally(requestSignal.cleanup)
if (response.status === 503 && response.headers.has('retry-after')) {
await handleRetryAfter(response, interval, pollingSignal, true)
+176
View File
@@ -30,8 +30,184 @@ function createConfig(agent: undici.MockAgent) {
return config
}
function createSignalTrackingConfig() {
const seenSignals: AbortSignal[] = []
const config = new client.Configuration(
{
issuer: issuer.href,
token_endpoint: `${issuer.origin}/token`,
},
'client_id',
)
config[client.customFetch] = async (_url, options) => {
if (options.signal) {
seenSignals.push(options.signal)
}
return new Response(
JSON.stringify({
access_token: 'access_token',
token_type: 'bearer',
}),
{
headers: {
'content-type': 'application/json',
},
},
)
}
return { config, seenSignals }
}
// Tests for Device Authorization Grant polling with retry-after
test('pollBackchannelAuthenticationGrant - passes abort signal to token request', async (t) => {
const { config, seenSignals } = createSignalTrackingConfig()
const controller = new AbortController()
await client.pollBackchannelAuthenticationGrant(
config,
{
auth_req_id: 'req-id',
expires_in: 600,
interval: 0,
},
undefined,
{ signal: controller.signal },
)
const [signal] = seenSignals
t.truthy(signal)
controller.abort()
t.true(signal.aborted)
})
test('pollDeviceAuthorizationGrant - passes abort signal to token request', async (t) => {
const { config, seenSignals } = createSignalTrackingConfig()
const controller = new AbortController()
await client.pollDeviceAuthorizationGrant(
config,
{
device_code: 'device123',
user_code: 'user123',
verification_uri: `${issuer.origin}/device`,
expires_in: 600,
interval: 0,
},
undefined,
{ signal: controller.signal },
)
const [signal] = seenSignals
t.truthy(signal)
controller.abort()
t.true(signal.aborted)
})
test('pollBackchannelAuthenticationGrant - passes abort signal to retried token request', async (t) => {
const { config, seenSignals } = createSignalTrackingConfig()
const controller = new AbortController()
let attempt = 0
config[client.customFetch] = async (_url, options) => {
if (options.signal) {
seenSignals.push(options.signal)
}
if (++attempt === 1) {
return new Response('Service Unavailable', {
status: 503,
headers: {
'retry-after': '0',
},
})
}
return new Response(
JSON.stringify({
access_token: 'access_token',
token_type: 'bearer',
}),
{
headers: {
'content-type': 'application/json',
},
},
)
}
await client.pollBackchannelAuthenticationGrant(
config,
{
auth_req_id: 'req-id',
expires_in: 600,
interval: 0,
},
undefined,
{ signal: controller.signal },
)
t.is(seenSignals.length, 2)
controller.abort()
t.true(seenSignals[0]!.aborted)
t.true(seenSignals[1]!.aborted)
})
test('pollDeviceAuthorizationGrant - passes abort signal to retried token request', async (t) => {
const { config, seenSignals } = createSignalTrackingConfig()
const controller = new AbortController()
let attempt = 0
config[client.customFetch] = async (_url, options) => {
if (options.signal) {
seenSignals.push(options.signal)
}
if (++attempt === 1) {
return new Response('Service Unavailable', {
status: 503,
headers: {
'retry-after': '0',
},
})
}
return new Response(
JSON.stringify({
access_token: 'access_token',
token_type: 'bearer',
}),
{
headers: {
'content-type': 'application/json',
},
},
)
}
await client.pollDeviceAuthorizationGrant(
config,
{
device_code: 'device123',
user_code: 'user123',
verification_uri: `${issuer.origin}/device`,
expires_in: 600,
interval: 0,
},
undefined,
{ signal: controller.signal },
)
t.is(seenSignals.length, 2)
controller.abort()
t.true(seenSignals[0]!.aborted)
t.true(seenSignals[1]!.aborted)
})
test('pollBackchannelAuthenticationGrant - respects retry-after header with numeric seconds', async (t) => {
const { agent, mockAgent } = await setupMockAgent()