Skip to content

Commit d60b0d2

Browse files
committed
feat: respect retry-after in CIBA and Device Authorization Grant polling
1 parent d26f7a3 commit d60b0d2

File tree

2 files changed

+661
-61
lines changed

2 files changed

+661
-61
lines changed

src/index.ts

Lines changed: 114 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,9 +2069,70 @@ export interface DeviceAuthorizationGrantPollOptions extends DPoPOptions {
20692069
signal?: AbortSignal
20702070
}
20712071

2072-
function wait(interval: number): Promise<void> {
2073-
return new Promise((resolve) => {
2074-
setTimeout(resolve, interval * 1000)
2072+
/**
2073+
* Gets the retry-after header and if it indicates a needed wait longer than the
2074+
* current interval, it waits for the (needed wait - current interval). Current
2075+
* interval is subtracted from the total wait here because it will be waited on
2076+
* by the subsequent poll operation anyway.
2077+
*/
2078+
async function handleRetryAfter(
2079+
response: Response,
2080+
currentInterval: number,
2081+
signal: AbortSignal,
2082+
throwIfInvalid = false,
2083+
): Promise<void> {
2084+
const retryAfter = response.headers.get('retry-after')?.trim()
2085+
if (retryAfter === undefined) return
2086+
2087+
let delaySeconds: number | undefined
2088+
if (/^\d+$/.test(retryAfter)) {
2089+
delaySeconds = parseInt(retryAfter, 10)
2090+
} else {
2091+
const retryDate = new Date(retryAfter)
2092+
if (Number.isFinite(retryDate.getTime())) {
2093+
const now = new Date()
2094+
const delayMs = retryDate.getTime() - now.getTime()
2095+
if (delayMs > 0) {
2096+
delaySeconds = Math.ceil(delayMs / 1000)
2097+
}
2098+
}
2099+
}
2100+
2101+
if (throwIfInvalid && !Number.isFinite(delaySeconds)) {
2102+
throw new oauth.OperationProcessingError(
2103+
'invalid Retry-After header value',
2104+
{ cause: response },
2105+
)
2106+
}
2107+
2108+
if (delaySeconds! > currentInterval) {
2109+
await wait(delaySeconds! - currentInterval, signal)
2110+
}
2111+
}
2112+
2113+
/**
2114+
* Waits for a given duration or until an AbortSignal gets aborted
2115+
*/
2116+
function wait(duration: number, signal: AbortSignal): Promise<void> {
2117+
return new Promise((resolve, reject) => {
2118+
const waitStep = (remaining: number) => {
2119+
try {
2120+
signal.throwIfAborted()
2121+
} catch (err) {
2122+
reject(err)
2123+
return
2124+
}
2125+
2126+
if (remaining <= 0) {
2127+
resolve()
2128+
return
2129+
}
2130+
2131+
const currentWait = Math.min(remaining, 5)
2132+
setTimeout(() => waitStep(remaining - currentWait), currentWait * 1000)
2133+
}
2134+
2135+
waitStep(duration)
20752136
})
20762137
}
20772138

@@ -2129,16 +2190,29 @@ export async function pollDeviceAuthorizationGrant(
21292190
AbortSignal.timeout(deviceAuthorizationResponse.expires_in * 1000)
21302191

21312192
try {
2132-
pollingSignal.throwIfAborted()
2193+
await wait(interval, pollingSignal)
21332194
} catch (err) {
21342195
errorHandler(err)
21352196
}
21362197

2137-
await wait(interval)
2138-
21392198
const { as, c, auth, fetch, tlsOnly, nonRepudiation, timeout, decrypt } =
21402199
int(config)
21412200

2201+
const retryPoll = (updatedInterval: number, flag?: typeof retry) =>
2202+
pollDeviceAuthorizationGrant(
2203+
config,
2204+
{
2205+
...deviceAuthorizationResponse,
2206+
interval: updatedInterval,
2207+
},
2208+
parameters,
2209+
{
2210+
...options,
2211+
signal: pollingSignal,
2212+
flag,
2213+
},
2214+
)
2215+
21422216
const response = await oauth
21432217
.deviceCodeGrantRequest(
21442218
as,
@@ -2156,6 +2230,12 @@ export async function pollDeviceAuthorizationGrant(
21562230
)
21572231
.catch(errorHandler)
21582232

2233+
if (response.status === 503 && response.headers.has('retry-after')) {
2234+
await handleRetryAfter(response, interval, pollingSignal, true)
2235+
await response.body?.cancel()
2236+
return retryPoll(interval)
2237+
}
2238+
21592239
const p = oauth.processDeviceCodeResponse(as, c, response, {
21602240
[oauth.jweDecrypt]: decrypt,
21612241
})
@@ -2165,19 +2245,7 @@ export async function pollDeviceAuthorizationGrant(
21652245
result = await p
21662246
} catch (err) {
21672247
if (retryable(err, options)) {
2168-
return pollDeviceAuthorizationGrant(
2169-
config,
2170-
{
2171-
...deviceAuthorizationResponse,
2172-
interval,
2173-
},
2174-
parameters,
2175-
{
2176-
...options,
2177-
signal: pollingSignal,
2178-
flag: retry,
2179-
},
2180-
)
2248+
return retryPoll(interval, retry)
21812249
}
21822250

21832251
if (err instanceof oauth.ResponseBodyError) {
@@ -2186,19 +2254,8 @@ export async function pollDeviceAuthorizationGrant(
21862254
case 'slow_down': // Fall through
21872255
interval += 5
21882256
case 'authorization_pending':
2189-
return pollDeviceAuthorizationGrant(
2190-
config,
2191-
{
2192-
...deviceAuthorizationResponse,
2193-
interval,
2194-
},
2195-
parameters,
2196-
{
2197-
...options,
2198-
signal: pollingSignal,
2199-
flag: undefined,
2200-
},
2201-
)
2257+
await handleRetryAfter(err.response, interval, pollingSignal)
2258+
return retryPoll(interval)
22022259
}
22032260
}
22042261

@@ -2375,16 +2432,29 @@ export async function pollBackchannelAuthenticationGrant(
23752432
AbortSignal.timeout(backchannelAuthenticationResponse.expires_in * 1000)
23762433

23772434
try {
2378-
pollingSignal.throwIfAborted()
2435+
await wait(interval, pollingSignal)
23792436
} catch (err) {
23802437
errorHandler(err)
23812438
}
23822439

2383-
await wait(interval)
2384-
23852440
const { as, c, auth, fetch, tlsOnly, nonRepudiation, timeout, decrypt } =
23862441
int(config)
23872442

2443+
const retryPoll = (updatedInterval: number, flag?: typeof retry) =>
2444+
pollBackchannelAuthenticationGrant(
2445+
config,
2446+
{
2447+
...backchannelAuthenticationResponse,
2448+
interval: updatedInterval,
2449+
},
2450+
parameters,
2451+
{
2452+
...options,
2453+
signal: pollingSignal,
2454+
flag,
2455+
},
2456+
)
2457+
23882458
const response = await oauth
23892459
.backchannelAuthenticationGrantRequest(
23902460
as,
@@ -2402,6 +2472,12 @@ export async function pollBackchannelAuthenticationGrant(
24022472
)
24032473
.catch(errorHandler)
24042474

2475+
if (response.status === 503 && response.headers.has('retry-after')) {
2476+
await handleRetryAfter(response, interval, pollingSignal, true)
2477+
await response.body?.cancel()
2478+
return retryPoll(interval)
2479+
}
2480+
24052481
const p = oauth.processBackchannelAuthenticationGrantResponse(
24062482
as,
24072483
c,
@@ -2416,19 +2492,7 @@ export async function pollBackchannelAuthenticationGrant(
24162492
result = await p
24172493
} catch (err) {
24182494
if (retryable(err, options)) {
2419-
return pollBackchannelAuthenticationGrant(
2420-
config,
2421-
{
2422-
...backchannelAuthenticationResponse,
2423-
interval,
2424-
},
2425-
parameters,
2426-
{
2427-
...options,
2428-
signal: pollingSignal,
2429-
flag: retry,
2430-
},
2431-
)
2495+
return retryPoll(interval, retry)
24322496
}
24332497

24342498
if (err instanceof oauth.ResponseBodyError) {
@@ -2437,19 +2501,8 @@ export async function pollBackchannelAuthenticationGrant(
24372501
case 'slow_down': // Fall through
24382502
interval += 5
24392503
case 'authorization_pending':
2440-
return pollBackchannelAuthenticationGrant(
2441-
config,
2442-
{
2443-
...backchannelAuthenticationResponse,
2444-
interval,
2445-
},
2446-
parameters,
2447-
{
2448-
...options,
2449-
signal: pollingSignal,
2450-
flag: undefined,
2451-
},
2452-
)
2504+
await handleRetryAfter(err.response, interval, pollingSignal)
2505+
return retryPoll(interval)
24532506
}
24542507
}
24552508

0 commit comments

Comments
 (0)