diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index e675cafd..ec286d60 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -123,17 +123,17 @@ function truncate(s, n) { /** * Map the OpenAI-style request body into the exact shape Qoder expects. */ -async function buildQoderRequestBody({ model, body, credentials, log }) { +async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) { const qoderKey = String(model || "").replace(/^qoder\//, ""); if (!QODER_MODEL_MAP[qoderKey]) { throw new Error(`Unsupported qoder model: "${qoderKey}" (received "${model}")`); } - let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log }); + let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal }); if (!modelConfig) { // Try a forced refresh once before giving up — the cache may simply // not be populated yet on first ever call for this credential. - const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log }); + const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal }); const retried = refreshed?.rawConfigs.get(qoderKey); if (!retried) { throw new Error( @@ -230,6 +230,54 @@ function wrapQoderSSE(response, model) { let buffer = ""; let doneEmitted = false; + // Process one already-extracted SSE line (no trailing newline). Returns + // false when the line indicated end-of-stream so the caller can stop + // forwarding any remaining chunks after [DONE]. + const processLine = (line, controller) => { + const trimmed = line.replace(/\r$/, "").trim(); + if (!trimmed) return; + if (!trimmed.startsWith("data:")) return; + if (doneEmitted) return; // never forward chunks past stream end + + const data = trimmed.slice(5).trimStart(); + if (data === "[DONE]") { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + return; + } + + let envelope; + try { envelope = JSON.parse(data); } catch { return; } + const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; + const inner = typeof envelope.body === "string" ? envelope.body : ""; + if (statusVal !== 200) { + const msg = inner || `upstream status ${statusVal}`; + const errChunk = JSON.stringify({ + id: `qoder-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }], + }); + controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`)); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + return; + } + if (!inner) return; + if (inner === "[DONE]") { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; + return; + } + // Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the + // SSE frame stays a single event (a literal "\n" inside `inner` would + // otherwise split the frame across multiple data: lines and downstream + // parsers would reassemble them as separate events). + const sanitized = inner.replace(/\r?\n/g, ""); + controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); + }; + const transform = new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); @@ -237,54 +285,24 @@ function wrapQoderSSE(response, model) { while ((nl = buffer.indexOf("\n")) !== -1) { const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1); - const trimmed = line.replace(/\r$/, "").trim(); - if (!trimmed) continue; - if (!trimmed.startsWith("data:")) continue; - - let data = trimmed.slice(5).trimStart(); - if (data === "[DONE]") { - if (!doneEmitted) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - doneEmitted = true; - } - continue; - } - - let envelope; - try { envelope = JSON.parse(data); } catch { continue; } - const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; - const inner = typeof envelope.body === "string" ? envelope.body : ""; - if (statusVal !== 200) { - const msg = inner || `upstream status ${statusVal}`; - const errChunk = JSON.stringify({ - id: `qoder-error-${Date.now()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model, - choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }], - }); - controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`)); - if (!doneEmitted) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - doneEmitted = true; - } - continue; - } - if (!inner) continue; - if (inner === "[DONE]") { - if (!doneEmitted) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); - doneEmitted = true; - } - continue; - } - // Inner is already an OpenAI-shaped chunk; forward as-is. - controller.enqueue(encoder.encode(`data: ${inner}\n\n`)); + processLine(line, controller); } }, flush(controller) { + // Finalize the decoder so any pending multi-byte sequence is + // released into `buffer` instead of being silently dropped. + buffer += decoder.decode(); + // Drain any trailing line that arrived without a terminating newline + // (e.g. upstream closed the socket immediately after the last write, + // or a CDN stripped the final CRLF). Without this, the chunk that + // carries finish_reason is silently lost. + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } if (!doneEmitted) { controller.enqueue(encoder.encode("data: [DONE]\n\n")); + doneEmitted = true; } }, }); @@ -329,11 +347,20 @@ export class QoderExecutor extends BaseExecutor { ); return { response: fakeResp, url, headers: {}, transformedBody: body }; } + if (!credentials?.accessToken) { + // Same shape as the userId guard — clean 401 so chatCore reports + // "reconnect" rather than bubbling cosy.js's synchronous throw as 500. + const fakeResp = new Response( + JSON.stringify({ error: { message: "qoder credential is missing accessToken; reconnect the account" } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } let qoderKey; let payload; try { - ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log })); + ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal })); } catch (err) { const fakeResp = new Response( JSON.stringify({ error: { message: err.message } }), @@ -346,17 +373,28 @@ export class QoderExecutor extends BaseExecutor { const encodedBodyStr = qoderEncodeBody(plainBody); const encodedBodyBuf = Buffer.from(encodedBodyStr, "latin1"); - const cosyHeaders = buildCosyHeaders( - encodedBodyBuf, - url, - { - userId: psd.userId, - authToken: credentials.accessToken, - name: credentials.displayName || "", - email: credentials.email || "", - machineId: psd.machineId || "", - }, - ); + let cosyHeaders; + try { + cosyHeaders = buildCosyHeaders( + encodedBodyBuf, + url, + { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }, + ); + } catch (err) { + // cosy.js throws synchronously on missing userId/authToken — surface + // as 401 so chatCore prompts re-auth instead of returning a 500. + const fakeResp = new Response( + JSON.stringify({ error: { message: `qoder cosy signing failed: ${err.message}` } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } const modelSource = (payload.model_config && payload.model_config.source) || "system"; const headers = { diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index 24cc165e..47c911ae 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -26,6 +26,14 @@ const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog /** @type {Map, fetched: boolean }>} */ const catalogCache = new Map(); +/** + * In-flight fetch promises keyed by cacheKey. Concurrent first-time + * callers (parallel chat windows) all observe the same Promise so we + * fan-out exactly one upstream request per credential per miss. + * @type {Map, fetched: boolean } | null>>} + */ +const inflight = new Map(); + /** * Stable cache key per credential (so different login sessions for the same * account share an entry). @@ -73,8 +81,15 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { try { timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); if (signal && typeof signal.addEventListener === "function") { - abortListener = () => controller.abort(signal.reason); - signal.addEventListener("abort", abortListener); + // If the parent signal already aborted before we got here, the + // 'abort' event has already fired and addEventListener won't + // re-trigger it. Propagate the cancellation immediately. + if (signal.aborted) { + controller.abort(signal.reason); + } else { + abortListener = () => controller.abort(signal.reason); + signal.addEventListener("abort", abortListener); + } } response = await proxyAwareFetch( QODER_MODEL_LIST_URL, @@ -137,7 +152,9 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) { /** * Resolve the live model catalog + raw configs for a credential. Caches - * results for CACHE_TTL_MS so repeated chat requests don't re-fetch. + * results for CACHE_TTL_MS so repeated chat requests don't re-fetch, and + * deduplicates concurrent misses so parallel chat windows fan-out exactly + * one upstream request per credential. */ export async function resolveQoderModels(credentials, options = {}) { if (!credentials?.accessToken) return null; @@ -153,17 +170,36 @@ export async function resolveQoderModels(credentials, options = {}) { } } - const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); - if (!fetched) return null; + // Coalesce concurrent misses on the same credential into one upstream call. + // forceRefresh callers still get their own fetch (they wanted fresh data). + const existing = inflight.get(key); + if (existing && !options.forceRefresh) { + return existing; + } - const entry = { - expiresAt: now + CACHE_TTL_MS, - models: fetched.models, - rawConfigs: fetched.rawConfigs, - fetched: true, - }; - catalogCache.set(key, entry); - return entry; + const fetchPromise = (async () => { + const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); + if (!fetched) return null; + const entry = { + expiresAt: Date.now() + CACHE_TTL_MS, + models: fetched.models, + rawConfigs: fetched.rawConfigs, + fetched: true, + }; + catalogCache.set(key, entry); + return entry; + })(); + + inflight.set(key, fetchPromise); + try { + return await fetchPromise; + } finally { + // Clear only if this is still the in-flight entry — a forceRefresh + // call that started later may have replaced it. + if (inflight.get(key) === fetchPromise) { + inflight.delete(key); + } + } } export function invalidateQoderCatalog(credentials) { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index fc60b7a9..364a1236 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -63,11 +63,13 @@ const OAUTH_TEST_CONFIG = { kiro: { checkExpiry: true, refreshable: true }, qoder: { // Test by hitting Qoder's userinfo endpoint with the device token. + // refreshable: false because the device-flow refresh endpoint returns + // 403 for our flow (users re-login when expired). No checkExpiry — + // we want the actual URL probe to run so revoked tokens surface. url: "https://openapi.qoder.sh/api/v1/userinfo", method: "GET", authHeader: "Authorization", authPrefix: "Bearer ", - checkExpiry: true, refreshable: false, }, "kimi-coding": { checkExpiry: true, refreshable: false }, diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 0464d6b5..12c8b446 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -671,8 +671,14 @@ const PROVIDERS = { }; }, mapTokens: (tokens) => { - const email = (tokens._qoderEmail || "").trim() || null; + const rawEmail = (tokens._qoderEmail || "").trim(); const displayName = (tokens._qoderName || "").trim() || null; + const userId = tokens._qoderUserId || ""; + // Dedup in createProviderConnection requires a non-empty email. When + // fetchUserInfo silently fails (returns ""), fall back to a stable + // synthetic identifier derived from userId so re-logins update the + // existing row instead of accumulating "Account N" duplicates. + const email = rawEmail || (userId ? `qoder-user-${userId}` : null); return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || null, @@ -681,7 +687,7 @@ const PROVIDERS = { displayName, providerSpecificData: { authMethod: "device", - userId: tokens._qoderUserId || "", + userId, machineId: tokens._qoderMachineId || "", organizationId: tokens._qoderOrganizationId || "", }, diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js index 33cea03c..352762ce 100644 --- a/src/lib/oauth/services/index.js +++ b/src/lib/oauth/services/index.js @@ -8,7 +8,6 @@ export { CodexService } from "./codex.js"; export { GeminiCLIService } from "./gemini.js"; export { QwenService } from "./qwen.js"; export { IFlowService } from "./iflow.js"; -export { QoderService } from "./qoder.js"; export { AntigravityService } from "./antigravity.js"; export { OpenAIService } from "./openai.js"; export { GitHubService } from "./github.js"; diff --git a/src/lib/oauth/services/qoder.js b/src/lib/oauth/services/qoder.js deleted file mode 100644 index 39f7caf9..00000000 --- a/src/lib/oauth/services/qoder.js +++ /dev/null @@ -1,232 +0,0 @@ -import crypto from "crypto"; -import open from "open"; -import { QODER_CONFIG } from "../constants/oauth.js"; -import { getServerCredentials } from "../config/index.js"; -import { startLocalServer } from "../utils/server.js"; -import { spinner as createSpinner } from "../utils/ui.js"; - -/** - * Qoder OAuth Service - * Uses Authorization Code flow with Basic Auth - */ -export class QoderService { - constructor() { - this.config = QODER_CONFIG; - } - - /** - * Build Qoder authorization URL - */ - buildAuthUrl(redirectUri, state) { - const params = new URLSearchParams({ - client_id: this.config.clientId, - response_type: "code", - redirect_uri: redirectUri, - state: state, - }); - - return `${this.config.authorizeUrl}?${params.toString()}`; - } - - /** - * Exchange authorization code for tokens - */ - async exchangeCode(code, redirectUri) { - const basicAuth = Buffer.from( - `${this.config.clientId}:${this.config.clientSecret}` - ).toString("base64"); - - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirectUri, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - } - - /** - * Refresh access token using refresh token - */ - async refreshToken(refreshToken) { - const basicAuth = Buffer.from( - `${this.config.clientId}:${this.config.clientSecret}` - ).toString("base64"); - - const response = await fetch(this.config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: this.config.clientId, - client_secret: this.config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token refresh failed: ${error}`); - } - - return await response.json(); - } - - /** - * Get user info from Qoder - */ - async getUserInfo(accessToken) { - const response = await fetch( - `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, - { headers: { Accept: "application/json" } } - ); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Failed to get user info: ${error}`); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error("Failed to get user info"); - } - - return result.data; - } - - /** - * Save Qoder tokens to server - */ - async saveTokens(tokens, userInfo) { - const { server, token, userId } = getServerCredentials(); - - const response = await fetch(`${server}/api/cli/providers/qoder`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - "X-User-Id": userId, - }, - body: JSON.stringify({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - apiKey: userInfo.apiKey, - email: userInfo.email || userInfo.phone, - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "Failed to save tokens"); - } - - return await response.json(); - } - - /** - * Refresh and update tokens on server - */ - async refreshAndSave(existingRefreshToken) { - const spinner = createSpinner("Refreshing Qoder token...").start(); - - try { - const tokens = await this.refreshToken(existingRefreshToken); - const userInfo = await this.getUserInfo(tokens.access_token); - await this.saveTokens(tokens, userInfo); - spinner.succeed("Qoder token refreshed successfully"); - return tokens; - } catch (error) { - spinner.fail(`Token refresh failed: ${error.message}`); - throw error; - } - } - - /** - * Complete Qoder OAuth flow - */ - async connect() { - const spinner = createSpinner("Starting Qoder OAuth...").start(); - - try { - spinner.text = "Starting local server..."; - - let callbackParams = null; - const { port, close } = await startLocalServer((params) => { - callbackParams = params; - }); - - const redirectUri = `http://localhost:${port}/callback`; - spinner.succeed(`Local server started on port ${port}`); - - const state = crypto.randomBytes(32).toString("base64url"); - const authUrl = this.buildAuthUrl(redirectUri, state); - - console.log("\nOpening browser for Qoder authentication..."); - console.log(`If browser doesn't open, visit:\n${authUrl}\n`); - - await open(authUrl); - - spinner.start("Waiting for Qoder authorization..."); - - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error("Authentication timeout (5 minutes)")); - }, 300000); - - const checkInterval = setInterval(() => { - if (callbackParams) { - clearInterval(checkInterval); - clearTimeout(timeout); - resolve(); - } - }, 100); - }); - - close(); - - if (callbackParams.error) { - throw new Error(callbackParams.error_description || callbackParams.error); - } - - if (!callbackParams.code) { - throw new Error("No authorization code received"); - } - - spinner.start("Exchanging code for tokens..."); - const tokens = await this.exchangeCode(callbackParams.code, redirectUri); - - spinner.text = "Fetching user info..."; - const userInfo = await this.getUserInfo(tokens.access_token); - - spinner.text = "Saving tokens to server..."; - await this.saveTokens(tokens, userInfo); - - spinner.succeed(`Qoder connected successfully! (${userInfo.email || userInfo.phone})`); - return true; - } catch (error) { - spinner.fail(`Failed: ${error.message}`); - throw error; - } - } -} diff --git a/src/lib/qoder/auth.js b/src/lib/qoder/auth.js index ae0e2b1e..b1b9f53b 100644 --- a/src/lib/qoder/auth.js +++ b/src/lib/qoder/auth.js @@ -63,6 +63,26 @@ export function initiateDeviceFlow() { }; } +// Timeout for OAuth helper calls. The OAuth modal polls every 2s for up to +// 5 minutes; an individual request that stalls beyond this is treated as a +// failed poll attempt and the next poll iteration retries. +const FETCH_TIMEOUT_MS = 15_000; + +/** + * Wrap fetch with an AbortController-based timeout. Without this, a stalled + * upstream socket hangs on Node's default keepalive timeout (minutes) and + * abandoned polls accumulate hung sockets. + */ +async function fetchWithTimeout(url, init = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + /** * Single poll attempt. Returns one of: * { status: "pending" } — keep polling @@ -77,7 +97,7 @@ export async function pollDeviceToken({ nonce, codeVerifier }) { } const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`; - const response = await fetch(url, { + const response = await fetchWithTimeout(url, { method: "GET", headers: { Accept: "application/json", @@ -132,7 +152,7 @@ export async function pollDeviceToken({ nonce, codeVerifier }) { */ export async function fetchUserInfo(accessToken) { try { - const response = await fetch(QODER_USERINFO_URL, { + const response = await fetchWithTimeout(QODER_USERINFO_URL, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, @@ -154,18 +174,36 @@ export async function fetchUserInfo(accessToken) { /** * Convert the upstream's expiry hint into a Unix-millisecond timestamp. - * Accepts RFC3339 strings, ms-epoch integer strings, or seconds-from-now - * (`expires_in`). Falls back to "now + 30 days" when both are missing. + * Accepts: + * - numeric (ms-epoch): returned as-is + * - numeric string of ms-epoch: e.g. "1781594470000" + * - RFC3339 string: e.g. "2026-06-16T07:15:04Z" + * - seconds-from-now via expiresInSeconds (>= 0) + * Falls back to "now + 30 days" when both are missing. + * + * Order matters: try numeric (string or number) before Date.parse, since + * Date.parse accepts short numeric strings like "2026" as years and would + * otherwise return a misleading year-2026 timestamp instead of falling + * through to the integer branch. */ function parseExpiry(expiresAt, expiresInSeconds) { + if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > 0) { + return expiresAt; + } const trimmed = typeof expiresAt === "string" ? expiresAt.trim() : ""; if (trimmed) { + // Pure numeric string → ms-epoch (don't let Date.parse swallow short + // numerics as years). + if (/^\d+$/.test(trimmed)) { + const ms = Number.parseInt(trimmed, 10); + if (Number.isFinite(ms) && ms > 0) return ms; + } const parsed = Date.parse(trimmed); if (!Number.isNaN(parsed)) return parsed; - const ms = Number.parseInt(trimmed, 10); - if (!Number.isNaN(ms) && ms > 0) return ms; } - if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) { + // expiresInSeconds === 0 means "already expired"; honor that by returning + // the current time rather than fabricating a 30-day default. + if (typeof expiresInSeconds === "number" && Number.isFinite(expiresInSeconds) && expiresInSeconds >= 0) { return Date.now() + expiresInSeconds * 1000; } return Date.now() + 30 * 24 * 60 * 60 * 1000; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 1a6c9c95..7bc0c976 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -86,12 +86,16 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, }, [authData, onSuccess]); // Poll for device code token - const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData) => { + const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData, deadlineMs) => { pollingAbortRef.current = false; setPolling(true); - const maxAttempts = 60; + // Honor the upstream's expires_in when supplied (qoder sets 300s) so we + // don't time out earlier than the device code itself. Default 120s + // matches the prior behavior for providers that don't surface a value. + const startedAt = Date.now(); + const deadline = startedAt + (Number.isFinite(deadlineMs) && deadlineMs > 0 ? deadlineMs : 120_000); - for (let i = 0; i < maxAttempts; i++) { + while (Date.now() < deadline) { // Check if polling should be aborted if (pollingAbortRef.current) { console.log("[OAuthModal] Polling aborted"); @@ -193,7 +197,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, _qoderVerifier: data.codeVerifier, } : null; - startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData); + startPolling( + data.device_code, + data.codeVerifier, + data.interval || 5, + extraData, + // Use the upstream's expires_in if present so we don't time out + // before the device code itself (qoder gives 300s). + Number.isFinite(data.expires_in) && data.expires_in > 0 + ? data.expires_in * 1000 + : undefined, + ); return; }