mirror of
https://github.com/hanzoai/openid-client.git
synced 2026-09-24 17:01:10 +00:00
fix: preserve poll abort signals on requests
This commit is contained in:
+44
-2
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user