(
device: DeviceCodeResponse,
options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
)
| 235 | } |
| 236 | |
| 237 | export async function pollDeviceCodeToken( |
| 238 | device: DeviceCodeResponse, |
| 239 | options: XaiAuthPluginOptions & { sleep?: (ms: number) => Promise<void>; now?: () => number } = {}, |
| 240 | ): Promise<TokenResponse> { |
| 241 | const sleep = options.sleep ?? defaultSleep |
| 242 | const now = options.now ?? (() => Date.now()) |
| 243 | const expiresInMs = positiveSecondsToMs(device.expires_in, DEVICE_CODE_DEFAULT_EXPIRES_MS) |
| 244 | const deadline = now() + expiresInMs |
| 245 | let intervalMs = Math.max( |
| 246 | positiveSecondsToMs(device.interval, DEVICE_CODE_DEFAULT_INTERVAL_MS), |
| 247 | DEVICE_CODE_MIN_INTERVAL_MS, |
| 248 | ) |
| 249 | |
| 250 | while (now() < deadline) { |
| 251 | const response = await fetch(options.tokenUrl ?? TOKEN_URL, { |
| 252 | method: "POST", |
| 253 | headers: authHeaders(), |
| 254 | body: new URLSearchParams({ |
| 255 | grant_type: DEVICE_CODE_GRANT_TYPE, |
| 256 | client_id: CLIENT_ID, |
| 257 | device_code: device.device_code, |
| 258 | }).toString(), |
| 259 | }) |
| 260 | if (response.ok) return (await response.json()) as TokenResponse |
| 261 | |
| 262 | const body = (await response.json().catch(() => ({}))) as DeviceTokenErrorBody |
| 263 | const remaining = Math.max(0, deadline - now()) |
| 264 | // RFC 8628 §3.5: authorization_pending = keep polling at the same |
| 265 | // interval; slow_down = bump the interval by ≥5s and keep polling. |
| 266 | // Anything else is terminal. |
| 267 | if (body.error === "authorization_pending") { |
| 268 | await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) |
| 269 | continue |
| 270 | } |
| 271 | if (body.error === "slow_down") { |
| 272 | intervalMs += DEVICE_CODE_SLOW_DOWN_INCREMENT_MS |
| 273 | await sleep(Math.min(intervalMs + OAUTH_POLLING_SAFETY_MARGIN_MS, remaining)) |
| 274 | continue |
| 275 | } |
| 276 | if (body.error === "access_denied" || body.error === "authorization_denied") { |
| 277 | throw new Error("xAI device authorization was denied") |
| 278 | } |
| 279 | if (body.error === "expired_token") { |
| 280 | throw new Error("xAI device code expired - please re-run login") |
| 281 | } |
| 282 | const detail = body.error_description ?? body.error ?? "" |
| 283 | throw new Error(`xAI device token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) |
| 284 | } |
| 285 | throw new Error("xAI device authorization timed out") |
| 286 | } |
| 287 | |
| 288 | // CORS allowlist for the loopback callback. The redirect_uri itself is |
| 289 | // already bound to 127.0.0.1 and gated by PKCE+state, so we only accept |
no test coverage detected