Spaces:
Sleeping
Sleeping
| import { | |
| QODER_DEVICE_TOKEN_URL, | |
| QODER_LOGIN_URL, | |
| QODER_USERINFO_URL, | |
| } from "../../qoder/constants.js"; | |
| import crypto from "crypto"; | |
| import { v4 as uuidv4 } from "uuid"; | |
| /** | |
| * Qoder OAuth Service | |
| * Implements the device-token flow: | |
| * 1. Generate PKCE pair + nonce + machine_id locally. | |
| * 2. Open https://qoder.com/device/selectAccounts?challenge=...&nonce=... | |
| * in the user's browser. | |
| * 3. Poll openapi.qoder.sh/api/v1/deviceToken/poll until the user authorizes | |
| * and the upstream returns a `dt-...` access token. | |
| * | |
| * Tokens live ~30 days; refresh is a no-op (the upstream refresh endpoint | |
| * returns 403 for our flow). Users re-run login when expired. | |
| * | |
| * Mirrors the structure of KiroService β the COSY signing / WAF-bypass body | |
| * encoding / chat protocol live separately in src/lib/qoder/ because they're | |
| * used by every signed request, not just OAuth. | |
| */ | |
| // 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; | |
| function base64Url(buf) { | |
| return buf | |
| .toString("base64") | |
| .replace(/=/g, "") | |
| .replace(/\+/g, "-") | |
| .replace(/\//g, "_"); | |
| } | |
| /** | |
| * 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); | |
| } | |
| } | |
| export class QoderService { | |
| /** | |
| * Generate a PKCE verifier + S256 challenge pair. | |
| * Uses 32 random bytes (matches qodercli/Veria). | |
| */ | |
| generatePkcePair() { | |
| const verifier = base64Url(crypto.randomBytes(32)); | |
| const challenge = base64Url(crypto.createHash("sha256").update(verifier).digest()); | |
| return { verifier, challenge }; | |
| } | |
| /** | |
| * Initiate the device flow. Returns the URL to open in a browser plus the | |
| * verifier/nonce/machineId we'll need to poll and to sign future requests. | |
| */ | |
| initiateDeviceFlow() { | |
| const { verifier, challenge } = this.generatePkcePair(); | |
| const nonce = uuidv4(); | |
| const machineId = uuidv4(); | |
| const params = new URLSearchParams({ | |
| challenge, | |
| challenge_method: "S256", | |
| machine_id: machineId, | |
| nonce, | |
| }); | |
| return { | |
| verificationUriComplete: `${QODER_LOGIN_URL}?${params.toString()}`, | |
| codeVerifier: verifier, | |
| nonce, | |
| machineId, | |
| }; | |
| } | |
| /** | |
| * Single poll attempt. Returns one of: | |
| * { status: "pending" } β keep polling | |
| * { status: "ok", token, ... } β user authorized, tokens captured | |
| * throws Error β terminal failure | |
| * | |
| * Upstream returns 202/404 while waiting; 200 with a JSON body when done. | |
| */ | |
| async pollDeviceToken({ nonce, codeVerifier }) { | |
| if (!nonce || !codeVerifier) { | |
| throw new Error("pollDeviceToken: missing nonce or code verifier"); | |
| } | |
| const url = `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`; | |
| const response = await fetchWithTimeout(url, { | |
| method: "GET", | |
| headers: { | |
| Accept: "application/json", | |
| "User-Agent": "Go-http-client/2.0", | |
| }, | |
| }); | |
| // Pending β server has registered the device code but the user hasn't | |
| // finished the browser flow yet. Both 202 and 404 mean "keep polling". | |
| if (response.status === 202 || response.status === 404) { | |
| return { status: "pending" }; | |
| } | |
| const text = await response.text(); | |
| if (!response.ok) { | |
| let message = `Qoder device token poll failed: HTTP ${response.status}`; | |
| try { | |
| const body = JSON.parse(text); | |
| if (body.message) message = `Qoder device token poll failed: ${body.message}`; | |
| } catch {} | |
| throw new Error(message); | |
| } | |
| let body; | |
| try { | |
| body = JSON.parse(text); | |
| } catch (err) { | |
| throw new Error(`Qoder device token poll: invalid JSON response (${err.message})`); | |
| } | |
| // Defensive: 200 + empty token means the upstream changed shape. | |
| if (!body.token) { | |
| throw new Error("Qoder device token poll returned 200 but no token"); | |
| } | |
| const expireMs = QoderService.parseExpiry(body.expires_at, body.expires_in); | |
| return { | |
| status: "ok", | |
| accessToken: body.token, | |
| refreshToken: body.refresh_token || "", | |
| userId: body.user_id || "", | |
| expireTime: expireMs, | |
| rawResponse: body, | |
| }; | |
| } | |
| /** | |
| * Fetch profile info for the freshly-issued token. Best-effort β failures | |
| * shouldn't block login; returning empty strings is fine. | |
| */ | |
| async fetchUserInfo(accessToken) { | |
| try { | |
| const response = await fetchWithTimeout(QODER_USERINFO_URL, { | |
| method: "GET", | |
| headers: { | |
| Authorization: `Bearer ${accessToken}`, | |
| Accept: "application/json", | |
| "User-Agent": "Go-http-client/2.0", | |
| }, | |
| }); | |
| if (!response.ok) return { name: "", email: "" }; | |
| const body = await response.json(); | |
| return { | |
| name: (body.name || body.username || "").trim(), | |
| email: (body.email || "").trim(), | |
| organizationId: (body.organization_id || "").trim(), | |
| }; | |
| } catch { | |
| return { name: "", email: "" }; | |
| } | |
| } | |
| /** | |
| * Convert the upstream's expiry hint into a Unix-millisecond timestamp. | |
| * 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. | |
| * | |
| * Static so callers (and tests) can use it without instantiating. | |
| */ | |
| static 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; | |
| } | |
| // 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; | |
| } | |
| } | |