mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
refactor(qoder): mirror Kiro's OAuth service layout
Move device-flow / poll / userinfo / parseExpiry from src/lib/qoder/auth.js
into a QoderService class at src/lib/oauth/services/qoder.js, matching how
KiroService is organized. Also re-add the QoderService re-export from
services/index.js.
The split now mirrors Kiro:
src/lib/oauth/services/qoder.js OAuth flow (was auth.js)
src/lib/qoder/cosy.js Per-request signing (unchanged)
src/lib/qoder/encoding.js WAF-bypass body (unchanged)
src/lib/qoder/constants.js Endpoints + model map (unchanged)
Behavior is unchanged — same functions, same signatures, just relocated
into a class so the import path lines up with `import { QoderService } from
"@/lib/oauth/services"` like every other OAuth provider. parseExpiry is now
a static method so callers and tests can use it without instantiating.
42 tests still pass; build still clean.
This commit is contained in:
@@ -605,8 +605,8 @@ const PROVIDERS = {
|
|||||||
// locally, the user lands on qoder.com/device/selectAccounts in the
|
// locally, the user lands on qoder.com/device/selectAccounts in the
|
||||||
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
|
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
|
||||||
requestDeviceCode: async (config) => {
|
requestDeviceCode: async (config) => {
|
||||||
const { initiateDeviceFlow } = await import("@/lib/qoder/auth");
|
const { QoderService } = await import("@/lib/oauth/services/qoder");
|
||||||
const flow = initiateDeviceFlow();
|
const flow = new QoderService().initiateDeviceFlow();
|
||||||
// Match the device_code shape the rest of the OAuthModal expects
|
// Match the device_code shape the rest of the OAuthModal expects
|
||||||
// (device_code, user_code, verification_uri[_complete], interval).
|
// (device_code, user_code, verification_uri[_complete], interval).
|
||||||
// The poll endpoint identifies us by nonce+verifier, not by a
|
// The poll endpoint identifies us by nonce+verifier, not by a
|
||||||
@@ -626,7 +626,8 @@ const PROVIDERS = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||||
const { pollDeviceToken, fetchUserInfo } = await import("@/lib/qoder/auth");
|
const { QoderService } = await import("@/lib/oauth/services/qoder");
|
||||||
|
const svc = new QoderService();
|
||||||
const nonce = deviceCode || extraData?._qoderNonce;
|
const nonce = deviceCode || extraData?._qoderNonce;
|
||||||
const verifier = codeVerifier || extraData?._qoderVerifier;
|
const verifier = codeVerifier || extraData?._qoderVerifier;
|
||||||
if (!nonce || !verifier) {
|
if (!nonce || !verifier) {
|
||||||
@@ -637,7 +638,7 @@ const PROVIDERS = {
|
|||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await pollDeviceToken({ nonce, codeVerifier: verifier });
|
result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -648,11 +649,12 @@ const PROVIDERS = {
|
|||||||
return { ok: false, data: { error: "authorization_pending" } };
|
return { ok: false, data: { error: "authorization_pending" } };
|
||||||
}
|
}
|
||||||
// Best-effort profile lookup so we have a name/email to display.
|
// Best-effort profile lookup so we have a name/email to display.
|
||||||
const userInfo = await fetchUserInfo(result.accessToken);
|
const userInfo = await svc.fetchUserInfo(result.accessToken);
|
||||||
// expireTime is a Unix-ms timestamp from parseExpiry, which already
|
// expireTime is a Unix-ms timestamp from QoderService.parseExpiry,
|
||||||
// falls back to "now + 30 days" when the upstream omits expiry. Floor
|
// which already falls back to "now + 30 days" when the upstream
|
||||||
// to a sane minimum (1 day) so a stale or skewed upstream timestamp
|
// omits expiry. Floor to a sane minimum (1 day) so a stale or
|
||||||
// doesn't truncate the stored token below something useful.
|
// skewed upstream timestamp doesn't truncate the stored token below
|
||||||
|
// something useful.
|
||||||
const minSeconds = 24 * 60 * 60;
|
const minSeconds = 24 * 60 * 60;
|
||||||
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
|
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
|
||||||
const expiresIn = Math.max(minSeconds, remainingSeconds);
|
const expiresIn = Math.max(minSeconds, remainingSeconds);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export { CodexService } from "./codex.js";
|
|||||||
export { GeminiCLIService } from "./gemini.js";
|
export { GeminiCLIService } from "./gemini.js";
|
||||||
export { QwenService } from "./qwen.js";
|
export { QwenService } from "./qwen.js";
|
||||||
export { IFlowService } from "./iflow.js";
|
export { IFlowService } from "./iflow.js";
|
||||||
|
export { QoderService } from "./qoder.js";
|
||||||
export { AntigravityService } from "./antigravity.js";
|
export { AntigravityService } from "./antigravity.js";
|
||||||
export { OpenAIService } from "./openai.js";
|
export { OpenAIService } from "./openai.js";
|
||||||
export { GitHubService } from "./github.js";
|
export { GitHubService } from "./github.js";
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
/**
|
|
||||||
* Qoder device flow authentication.
|
|
||||||
*
|
|
||||||
* The flow has three steps:
|
|
||||||
* 1. Generate a PKCE pair locally and a fresh nonce + machine id.
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import crypto from "crypto";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
import {
|
|
||||||
QODER_DEVICE_TOKEN_URL,
|
|
||||||
QODER_LOGIN_URL,
|
|
||||||
QODER_USERINFO_URL,
|
|
||||||
} from "./constants.js";
|
|
||||||
|
|
||||||
function base64Url(buf) {
|
|
||||||
return buf
|
|
||||||
.toString("base64")
|
|
||||||
.replace(/=/g, "")
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a PKCE verifier + S256 challenge pair.
|
|
||||||
* Uses 32 random bytes (matches qodercli/Veria).
|
|
||||||
*/
|
|
||||||
export function 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.
|
|
||||||
*/
|
|
||||||
export function initiateDeviceFlow() {
|
|
||||||
const { verifier, challenge } = 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
* { status: "ok", token, ... } — user authorized, tokens captured
|
|
||||||
* throws Error — terminal failure
|
|
||||||
*
|
|
||||||
* Upstream returns 202/404 while waiting; 200 with a JSON body when done.
|
|
||||||
*/
|
|
||||||
export async function 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 = 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.
|
|
||||||
*/
|
|
||||||
export async function 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.
|
|
||||||
*/
|
|
||||||
export 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;
|
|
||||||
}
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,17 @@ import crypto from "crypto";
|
|||||||
|
|
||||||
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
import { qoderEncodeBody } from "../../src/lib/qoder/encoding.js";
|
||||||
import { buildCosyHeaders } from "../../src/lib/qoder/cosy.js";
|
import { buildCosyHeaders } from "../../src/lib/qoder/cosy.js";
|
||||||
import { initiateDeviceFlow, generatePkcePair, parseExpiry } from "../../src/lib/qoder/auth.js";
|
import { QoderService } from "../../src/lib/oauth/services/qoder.js";
|
||||||
import { QODER_CHAT_URL_ENCODED, QODER_MODEL_LIST_URL } from "../../src/lib/qoder/constants.js";
|
import { QODER_CHAT_URL_ENCODED, QODER_MODEL_LIST_URL } from "../../src/lib/qoder/constants.js";
|
||||||
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
|
import { __test__ as qoderExecutorInternals } from "../../open-sse/executors/qoder.js";
|
||||||
|
|
||||||
|
// Convenience aliases — tests were originally written against module-level
|
||||||
|
// helpers; the QoderService class wraps them so each test creates its own
|
||||||
|
// instance to avoid hidden state.
|
||||||
|
const generatePkcePair = () => new QoderService().generatePkcePair();
|
||||||
|
const initiateDeviceFlow = () => new QoderService().initiateDeviceFlow();
|
||||||
|
const parseExpiry = QoderService.parseExpiry;
|
||||||
|
|
||||||
describe("qoderEncodeBody", () => {
|
describe("qoderEncodeBody", () => {
|
||||||
it("preserves base64 length (input length divisible by 3)", () => {
|
it("preserves base64 length (input length divisible by 3)", () => {
|
||||||
const input = Buffer.from("abcdef", "utf8"); // 6 bytes → 8 base64 chars
|
const input = Buffer.from("abcdef", "utf8"); // 6 bytes → 8 base64 chars
|
||||||
|
|||||||
Reference in New Issue
Block a user