opencode-qwen-cli-auth 2.2.9 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -62
- package/README.vi.md +273 -0
- package/dist/index.js +413 -92
- package/dist/lib/auth/auth.d.ts +51 -1
- package/dist/lib/auth/auth.js +738 -3
- package/dist/lib/auth/browser.js +14 -4
- package/dist/lib/config.d.ts +8 -0
- package/dist/lib/config.js +99 -1
- package/dist/lib/constants.js +99 -18
- package/dist/lib/logger.js +58 -12
- package/package.json +1 -1
package/dist/lib/auth/auth.js
CHANGED
|
@@ -1,25 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OAuth authentication utilities for Qwen Plugin
|
|
3
|
+
* Implements OAuth 2.0 Device Authorization Grant flow (RFC 8628)
|
|
4
|
+
* Handles token storage, refresh, and validation
|
|
5
|
+
* @license MIT
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import { generatePKCE } from "@openauthjs/openauth/pkce";
|
|
2
9
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
|
|
3
10
|
import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
|
|
4
|
-
import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
|
|
11
|
+
import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath, getAccountsPath, getAccountsLockPath } from "../config.js";
|
|
5
12
|
import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
|
|
6
|
-
|
|
7
|
-
|
|
13
|
+
|
|
14
|
+
/** Maximum number of retries for token refresh operations */
|
|
15
|
+
const MAX_REFRESH_RETRIES = 2;
|
|
16
|
+
/** Delay between retry attempts in milliseconds */
|
|
17
|
+
const REFRESH_RETRY_DELAY_MS = 2000;
|
|
18
|
+
/** Timeout for OAuth HTTP requests in milliseconds */
|
|
8
19
|
const OAUTH_REQUEST_TIMEOUT_MS = 15000;
|
|
20
|
+
/** Lock timeout for multi-process token refresh coordination */
|
|
9
21
|
const LOCK_TIMEOUT_MS = 10000;
|
|
22
|
+
/** Interval between lock acquisition attempts */
|
|
10
23
|
const LOCK_ATTEMPT_INTERVAL_MS = 100;
|
|
24
|
+
/** Backoff multiplier for lock retry interval */
|
|
11
25
|
const LOCK_BACKOFF_MULTIPLIER = 1.5;
|
|
26
|
+
/** Maximum interval between lock attempts */
|
|
12
27
|
const LOCK_MAX_INTERVAL_MS = 2000;
|
|
28
|
+
/** Maximum number of lock acquisition attempts */
|
|
13
29
|
const LOCK_MAX_ATTEMPTS = 20;
|
|
30
|
+
/** Account schema version for ~/.qwen/oauth_accounts.json */
|
|
31
|
+
const ACCOUNT_STORE_VERSION = 1;
|
|
32
|
+
/** Default cooldown when account hits insufficient_quota */
|
|
33
|
+
const DEFAULT_QUOTA_COOLDOWN_MS = 30 * 60 * 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Checks if an error is an AbortError (from AbortController)
|
|
37
|
+
* @param {*} error - The error to check
|
|
38
|
+
* @returns {boolean} True if error is an AbortError
|
|
39
|
+
*/
|
|
14
40
|
function isAbortError(error) {
|
|
15
41
|
return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
|
|
16
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Checks if an error has a specific error code (for Node.js system errors)
|
|
46
|
+
* @param {*} error - The error to check
|
|
47
|
+
* @param {string} code - The error code to look for (e.g., "EEXIST", "ENOENT")
|
|
48
|
+
* @returns {boolean} True if error has the specified code
|
|
49
|
+
*/
|
|
17
50
|
function hasErrorCode(error, code) {
|
|
18
51
|
return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
|
|
19
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Creates a promise that resolves after specified milliseconds
|
|
56
|
+
* @param {number} ms - Milliseconds to sleep
|
|
57
|
+
* @returns {Promise<void>} Promise that resolves after delay
|
|
58
|
+
*/
|
|
20
59
|
function sleep(ms) {
|
|
21
60
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Performs fetch with timeout using AbortController
|
|
64
|
+
* Automatically aborts request if it exceeds timeout
|
|
65
|
+
* @param {string} url - URL to fetch
|
|
66
|
+
* @param {RequestInit} [init] - Fetch options
|
|
67
|
+
* @param {number} [timeoutMs=OAUTH_REQUEST_TIMEOUT_MS] - Timeout in milliseconds
|
|
68
|
+
* @returns {Promise<Response>} Fetch response
|
|
69
|
+
* @throws {Error} If request times out
|
|
70
|
+
*/
|
|
23
71
|
async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
|
|
24
72
|
const controller = new AbortController();
|
|
25
73
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -39,14 +87,23 @@ async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS)
|
|
|
39
87
|
clearTimeout(timeoutId);
|
|
40
88
|
}
|
|
41
89
|
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Normalizes resource URL to valid HTTPS URL format
|
|
93
|
+
* Adds https:// prefix if missing and validates URL format
|
|
94
|
+
* @param {string|undefined} resourceUrl - URL to normalize
|
|
95
|
+
* @returns {string|undefined} Normalized URL or undefined if invalid
|
|
96
|
+
*/
|
|
42
97
|
function normalizeResourceUrl(resourceUrl) {
|
|
43
98
|
if (!resourceUrl)
|
|
44
99
|
return undefined;
|
|
45
100
|
try {
|
|
46
101
|
let normalizedUrl = resourceUrl;
|
|
102
|
+
// Add https:// prefix if protocol is missing
|
|
47
103
|
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
|
48
104
|
normalizedUrl = `https://${normalizedUrl}`;
|
|
49
105
|
}
|
|
106
|
+
// Validate URL format
|
|
50
107
|
new URL(normalizedUrl);
|
|
51
108
|
if (LOGGING_ENABLED) {
|
|
52
109
|
logInfo("Valid resource_url found and normalized:", normalizedUrl);
|
|
@@ -58,21 +115,37 @@ function normalizeResourceUrl(resourceUrl) {
|
|
|
58
115
|
return undefined;
|
|
59
116
|
}
|
|
60
117
|
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validates OAuth token response has required fields
|
|
121
|
+
* @param {Object} json - Token response JSON
|
|
122
|
+
* @param {string} context - Context for error messages (e.g., "token response", "refresh response")
|
|
123
|
+
* @returns {boolean} True if response is valid
|
|
124
|
+
*/
|
|
61
125
|
function validateTokenResponse(json, context) {
|
|
126
|
+
// Check access_token exists and is string
|
|
62
127
|
if (!json.access_token || typeof json.access_token !== "string") {
|
|
63
128
|
logError(`${context} missing access_token`);
|
|
64
129
|
return false;
|
|
65
130
|
}
|
|
131
|
+
// Check refresh_token exists and is string
|
|
66
132
|
if (!json.refresh_token || typeof json.refresh_token !== "string") {
|
|
67
133
|
logError(`${context} missing refresh_token`);
|
|
68
134
|
return false;
|
|
69
135
|
}
|
|
136
|
+
// Check expires_in is valid positive number
|
|
70
137
|
if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
|
|
71
138
|
logError(`${context} invalid expires_in:`, json.expires_in);
|
|
72
139
|
return false;
|
|
73
140
|
}
|
|
74
141
|
return true;
|
|
75
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Converts raw token data to standardized stored token format
|
|
145
|
+
* Handles different field name variations (expiry_date vs expires)
|
|
146
|
+
* @param {Object} data - Raw token data from OAuth response or file
|
|
147
|
+
* @returns {Object|null} Normalized token data or null if invalid
|
|
148
|
+
*/
|
|
76
149
|
function toStoredTokenData(data) {
|
|
77
150
|
if (!data || typeof data !== "object") {
|
|
78
151
|
return null;
|
|
@@ -81,6 +154,7 @@ function toStoredTokenData(data) {
|
|
|
81
154
|
const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
|
|
82
155
|
const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
|
|
83
156
|
const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
|
|
157
|
+
// Handle both expiry_date and expires field names
|
|
84
158
|
const expiryDate = typeof raw.expiry_date === "number"
|
|
85
159
|
? raw.expiry_date
|
|
86
160
|
: typeof raw.expires === "number"
|
|
@@ -89,6 +163,7 @@ function toStoredTokenData(data) {
|
|
|
89
163
|
? Number(raw.expiry_date)
|
|
90
164
|
: undefined;
|
|
91
165
|
const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
|
|
166
|
+
// Validate all required fields are present and valid
|
|
92
167
|
if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
|
|
93
168
|
return null;
|
|
94
169
|
}
|
|
@@ -100,6 +175,231 @@ function toStoredTokenData(data) {
|
|
|
100
175
|
resource_url: resourceUrl,
|
|
101
176
|
};
|
|
102
177
|
}
|
|
178
|
+
|
|
179
|
+
function getQuotaCooldownMs() {
|
|
180
|
+
const raw = process.env.OPENCODE_QWEN_QUOTA_COOLDOWN_MS;
|
|
181
|
+
if (typeof raw !== "string" || raw.trim().length === 0) {
|
|
182
|
+
return DEFAULT_QUOTA_COOLDOWN_MS;
|
|
183
|
+
}
|
|
184
|
+
const parsed = Number(raw);
|
|
185
|
+
if (!Number.isFinite(parsed) || parsed < 1000) {
|
|
186
|
+
return DEFAULT_QUOTA_COOLDOWN_MS;
|
|
187
|
+
}
|
|
188
|
+
return Math.floor(parsed);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeAccountStore(raw) {
|
|
192
|
+
const fallback = {
|
|
193
|
+
version: ACCOUNT_STORE_VERSION,
|
|
194
|
+
activeAccountId: null,
|
|
195
|
+
accounts: [],
|
|
196
|
+
};
|
|
197
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
198
|
+
return fallback;
|
|
199
|
+
}
|
|
200
|
+
const input = raw;
|
|
201
|
+
const accounts = Array.isArray(input.accounts) ? input.accounts : [];
|
|
202
|
+
const normalizedAccounts = [];
|
|
203
|
+
for (const item of accounts) {
|
|
204
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const token = toStoredTokenData(item.token);
|
|
208
|
+
if (!token) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const id = typeof item.id === "string" && item.id.trim().length > 0
|
|
212
|
+
? item.id.trim()
|
|
213
|
+
: `acct_${Math.random().toString(16).slice(2)}_${Date.now().toString(36)}`;
|
|
214
|
+
const createdAt = typeof item.createdAt === "number" && Number.isFinite(item.createdAt) ? item.createdAt : Date.now();
|
|
215
|
+
const updatedAt = typeof item.updatedAt === "number" && Number.isFinite(item.updatedAt) ? item.updatedAt : createdAt;
|
|
216
|
+
const exhaustedUntil = typeof item.exhaustedUntil === "number" && Number.isFinite(item.exhaustedUntil) ? item.exhaustedUntil : 0;
|
|
217
|
+
const lastErrorCode = typeof item.lastErrorCode === "string" ? item.lastErrorCode : undefined;
|
|
218
|
+
const accountKey = typeof item.accountKey === "string" && item.accountKey.trim().length > 0 ? item.accountKey.trim() : undefined;
|
|
219
|
+
normalizedAccounts.push({
|
|
220
|
+
id,
|
|
221
|
+
token,
|
|
222
|
+
resource_url: token.resource_url,
|
|
223
|
+
exhaustedUntil,
|
|
224
|
+
lastErrorCode,
|
|
225
|
+
accountKey,
|
|
226
|
+
createdAt,
|
|
227
|
+
updatedAt,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
let activeAccountId = typeof input.activeAccountId === "string" && input.activeAccountId.length > 0 ? input.activeAccountId : null;
|
|
231
|
+
if (activeAccountId && !normalizedAccounts.some(account => account.id === activeAccountId)) {
|
|
232
|
+
activeAccountId = null;
|
|
233
|
+
}
|
|
234
|
+
if (!activeAccountId && normalizedAccounts.length > 0) {
|
|
235
|
+
activeAccountId = normalizedAccounts[0].id;
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
version: ACCOUNT_STORE_VERSION,
|
|
239
|
+
activeAccountId,
|
|
240
|
+
accounts: normalizedAccounts,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function normalizeTokenResultToStored(tokenResult) {
|
|
245
|
+
if (!tokenResult || tokenResult.type !== "success") {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return toStoredTokenData({
|
|
249
|
+
access_token: tokenResult.access,
|
|
250
|
+
refresh_token: tokenResult.refresh,
|
|
251
|
+
token_type: "Bearer",
|
|
252
|
+
expiry_date: tokenResult.expires,
|
|
253
|
+
resource_url: tokenResult.resourceUrl,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseJwtPayloadSegment(token) {
|
|
258
|
+
if (typeof token !== "string") {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
const parts = token.split(".");
|
|
262
|
+
if (parts.length < 2) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
266
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
267
|
+
try {
|
|
268
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
|
269
|
+
}
|
|
270
|
+
catch (_error) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function deriveAccountKeyFromToken(tokenData) {
|
|
276
|
+
if (!tokenData || typeof tokenData !== "object") {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const payload = parseJwtPayloadSegment(tokenData.access_token);
|
|
280
|
+
if (payload && typeof payload === "object") {
|
|
281
|
+
const candidates = ["sub", "uid", "user_id", "email", "username"];
|
|
282
|
+
for (const key of candidates) {
|
|
283
|
+
const value = payload[key];
|
|
284
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
285
|
+
return `${key}:${value.trim()}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (typeof tokenData.refresh_token === "string" && tokenData.refresh_token.length > 12) {
|
|
290
|
+
return `refresh:${tokenData.refresh_token}`;
|
|
291
|
+
}
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildAccountEntry(tokenData, accountId, accountKey) {
|
|
296
|
+
const now = Date.now();
|
|
297
|
+
return {
|
|
298
|
+
id: accountId,
|
|
299
|
+
token: tokenData,
|
|
300
|
+
resource_url: tokenData.resource_url,
|
|
301
|
+
exhaustedUntil: 0,
|
|
302
|
+
lastErrorCode: undefined,
|
|
303
|
+
accountKey: accountKey || undefined,
|
|
304
|
+
createdAt: now,
|
|
305
|
+
updatedAt: now,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function writeAccountsStoreData(store) {
|
|
310
|
+
const qwenDir = getQwenDir();
|
|
311
|
+
if (!existsSync(qwenDir)) {
|
|
312
|
+
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
313
|
+
}
|
|
314
|
+
const accountsPath = getAccountsPath();
|
|
315
|
+
const tempPath = `${accountsPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
316
|
+
const payload = {
|
|
317
|
+
version: ACCOUNT_STORE_VERSION,
|
|
318
|
+
activeAccountId: store.activeAccountId || null,
|
|
319
|
+
accounts: store.accounts.map(account => ({
|
|
320
|
+
id: account.id,
|
|
321
|
+
token: account.token,
|
|
322
|
+
resource_url: account.resource_url,
|
|
323
|
+
exhaustedUntil: account.exhaustedUntil || 0,
|
|
324
|
+
lastErrorCode: account.lastErrorCode,
|
|
325
|
+
accountKey: account.accountKey,
|
|
326
|
+
createdAt: account.createdAt,
|
|
327
|
+
updatedAt: account.updatedAt,
|
|
328
|
+
})),
|
|
329
|
+
};
|
|
330
|
+
try {
|
|
331
|
+
writeFileSync(tempPath, JSON.stringify(payload, null, 2), {
|
|
332
|
+
encoding: "utf-8",
|
|
333
|
+
mode: 0o600,
|
|
334
|
+
});
|
|
335
|
+
renameSync(tempPath, accountsPath);
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
try {
|
|
339
|
+
if (existsSync(tempPath)) {
|
|
340
|
+
unlinkSync(tempPath);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (_cleanupError) {
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function loadAccountsStoreData() {
|
|
350
|
+
const path = getAccountsPath();
|
|
351
|
+
if (!existsSync(path)) {
|
|
352
|
+
return normalizeAccountStore(null);
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
356
|
+
return normalizeAccountStore(raw);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
logWarn("Failed to read oauth_accounts.json, using empty store", error);
|
|
360
|
+
return normalizeAccountStore(null);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function pickNextHealthyAccount(store, excludedIds = new Set(), now = Date.now()) {
|
|
365
|
+
const accounts = Array.isArray(store.accounts) ? store.accounts : [];
|
|
366
|
+
if (accounts.length === 0) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const activeIndex = accounts.findIndex(account => account.id === store.activeAccountId);
|
|
370
|
+
for (let step = 1; step <= accounts.length; step += 1) {
|
|
371
|
+
const index = activeIndex >= 0 ? (activeIndex + step) % accounts.length : (step - 1);
|
|
372
|
+
const candidate = accounts[index];
|
|
373
|
+
if (!candidate || excludedIds.has(candidate.id)) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (typeof candidate.exhaustedUntil === "number" && candidate.exhaustedUntil > now) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
return candidate;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function countHealthyAccounts(store, now = Date.now()) {
|
|
385
|
+
return store.accounts.filter(account => !(typeof account.exhaustedUntil === "number" && account.exhaustedUntil > now)).length;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function syncAccountToLegacyTokenFile(account) {
|
|
389
|
+
writeStoredTokenData({
|
|
390
|
+
access_token: account.token.access_token,
|
|
391
|
+
refresh_token: account.token.refresh_token,
|
|
392
|
+
token_type: account.token.token_type || "Bearer",
|
|
393
|
+
expiry_date: account.token.expiry_date,
|
|
394
|
+
resource_url: account.resource_url,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Builds token success object from stored token data
|
|
400
|
+
* @param {Object} stored - Stored token data from file
|
|
401
|
+
* @returns {Object} Token success object for SDK
|
|
402
|
+
*/
|
|
103
403
|
function buildTokenSuccessFromStored(stored) {
|
|
104
404
|
return {
|
|
105
405
|
type: "success",
|
|
@@ -109,12 +409,20 @@ function buildTokenSuccessFromStored(stored) {
|
|
|
109
409
|
resourceUrl: stored.resource_url,
|
|
110
410
|
};
|
|
111
411
|
}
|
|
412
|
+
/**
|
|
413
|
+
* Writes token data to disk atomically using temp file + rename
|
|
414
|
+
* Uses secure file permissions (0o600 - owner read/write only)
|
|
415
|
+
* @param {Object} tokenData - Token data to write
|
|
416
|
+
* @throws {Error} If write operation fails
|
|
417
|
+
*/
|
|
112
418
|
function writeStoredTokenData(tokenData) {
|
|
113
419
|
const qwenDir = getQwenDir();
|
|
420
|
+
// Create directory if it doesn't exist with secure permissions
|
|
114
421
|
if (!existsSync(qwenDir)) {
|
|
115
422
|
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
116
423
|
}
|
|
117
424
|
const tokenPath = getTokenPath();
|
|
425
|
+
// Use atomic write: write to temp file then rename
|
|
118
426
|
const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
119
427
|
try {
|
|
120
428
|
writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
|
|
@@ -124,6 +432,7 @@ function writeStoredTokenData(tokenData) {
|
|
|
124
432
|
renameSync(tempPath, tokenPath);
|
|
125
433
|
}
|
|
126
434
|
catch (error) {
|
|
435
|
+
// Clean up temp file on error
|
|
127
436
|
try {
|
|
128
437
|
if (existsSync(tempPath)) {
|
|
129
438
|
unlinkSync(tempPath);
|
|
@@ -134,12 +443,19 @@ function writeStoredTokenData(tokenData) {
|
|
|
134
443
|
throw error;
|
|
135
444
|
}
|
|
136
445
|
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Migrates legacy token from old plugin location to new location
|
|
449
|
+
* Checks if new token file exists, if not tries to migrate from legacy path
|
|
450
|
+
*/
|
|
137
451
|
function migrateLegacyTokenIfNeeded() {
|
|
138
452
|
const tokenPath = getTokenPath();
|
|
453
|
+
// Skip if new token file already exists
|
|
139
454
|
if (existsSync(tokenPath)) {
|
|
140
455
|
return;
|
|
141
456
|
}
|
|
142
457
|
const legacyPath = getLegacyTokenPath();
|
|
458
|
+
// Skip if legacy file doesn't exist
|
|
143
459
|
if (!existsSync(legacyPath)) {
|
|
144
460
|
return;
|
|
145
461
|
}
|
|
@@ -158,12 +474,54 @@ function migrateLegacyTokenIfNeeded() {
|
|
|
158
474
|
logWarn("Failed to migrate legacy token:", error);
|
|
159
475
|
}
|
|
160
476
|
}
|
|
477
|
+
|
|
478
|
+
function migrateLegacyTokenToAccountsIfNeeded() {
|
|
479
|
+
const accountsPath = getAccountsPath();
|
|
480
|
+
if (existsSync(accountsPath)) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const legacyToken = loadStoredToken();
|
|
484
|
+
if (!legacyToken) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const tokenData = toStoredTokenData(legacyToken);
|
|
488
|
+
if (!tokenData) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const accountKey = deriveAccountKeyFromToken(tokenData);
|
|
492
|
+
const accountId = `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
|
|
493
|
+
const store = normalizeAccountStore({
|
|
494
|
+
version: ACCOUNT_STORE_VERSION,
|
|
495
|
+
activeAccountId: accountId,
|
|
496
|
+
accounts: [buildAccountEntry(tokenData, accountId, accountKey)],
|
|
497
|
+
});
|
|
498
|
+
try {
|
|
499
|
+
writeAccountsStoreData(store);
|
|
500
|
+
if (LOGGING_ENABLED) {
|
|
501
|
+
logInfo("Migrated legacy oauth_creds.json to oauth_accounts.json");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
logWarn("Failed to migrate legacy token to oauth_accounts.json", error);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Acquires exclusive lock for token refresh to prevent concurrent refreshes
|
|
510
|
+
* Uses file-based locking with exponential backoff retry strategy
|
|
511
|
+
* @returns {Promise<string>} Lock file path if acquired successfully
|
|
512
|
+
* @throws {Error} If lock cannot be acquired within timeout
|
|
513
|
+
*/
|
|
161
514
|
async function acquireTokenLock() {
|
|
162
515
|
const lockPath = getTokenLockPath();
|
|
516
|
+
const qwenDir = getQwenDir();
|
|
517
|
+
if (!existsSync(qwenDir)) {
|
|
518
|
+
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
519
|
+
}
|
|
163
520
|
const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
164
521
|
let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
|
|
165
522
|
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
166
523
|
try {
|
|
524
|
+
// Try to create lock file with exclusive flag
|
|
167
525
|
writeFileSync(lockPath, lockValue, {
|
|
168
526
|
encoding: "utf-8",
|
|
169
527
|
flag: "wx",
|
|
@@ -172,12 +530,14 @@ async function acquireTokenLock() {
|
|
|
172
530
|
return lockPath;
|
|
173
531
|
}
|
|
174
532
|
catch (error) {
|
|
533
|
+
// EEXIST means lock file already exists
|
|
175
534
|
if (!hasErrorCode(error, "EEXIST")) {
|
|
176
535
|
throw error;
|
|
177
536
|
}
|
|
178
537
|
try {
|
|
179
538
|
const stats = statSync(lockPath);
|
|
180
539
|
const ageMs = Date.now() - stats.mtimeMs;
|
|
540
|
+
// Remove stale lock if it's older than timeout
|
|
181
541
|
if (ageMs > LOCK_TIMEOUT_MS) {
|
|
182
542
|
try {
|
|
183
543
|
unlinkSync(lockPath);
|
|
@@ -196,22 +556,112 @@ async function acquireTokenLock() {
|
|
|
196
556
|
logWarn("Failed to inspect token lock file", statError);
|
|
197
557
|
}
|
|
198
558
|
}
|
|
559
|
+
// Wait with exponential backoff before retry
|
|
199
560
|
await sleep(waitMs);
|
|
200
561
|
waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
|
|
201
562
|
}
|
|
202
563
|
}
|
|
203
564
|
throw new Error("Token refresh lock timeout");
|
|
204
565
|
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Releases token refresh lock
|
|
569
|
+
* Silently ignores errors if lock file doesn't exist
|
|
570
|
+
* @param {string} lockPath - Path to lock file to release
|
|
571
|
+
*/
|
|
205
572
|
function releaseTokenLock(lockPath) {
|
|
206
573
|
try {
|
|
207
574
|
unlinkSync(lockPath);
|
|
208
575
|
}
|
|
209
576
|
catch (error) {
|
|
577
|
+
// Ignore ENOENT (file not found) errors
|
|
210
578
|
if (!hasErrorCode(error, "ENOENT")) {
|
|
211
579
|
logWarn("Failed to release token lock file", error);
|
|
212
580
|
}
|
|
213
581
|
}
|
|
214
582
|
}
|
|
583
|
+
|
|
584
|
+
async function acquireAccountsLock() {
|
|
585
|
+
const lockPath = getAccountsLockPath();
|
|
586
|
+
const qwenDir = getQwenDir();
|
|
587
|
+
if (!existsSync(qwenDir)) {
|
|
588
|
+
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
589
|
+
}
|
|
590
|
+
const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
591
|
+
let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
|
|
592
|
+
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
593
|
+
try {
|
|
594
|
+
writeFileSync(lockPath, lockValue, {
|
|
595
|
+
encoding: "utf-8",
|
|
596
|
+
flag: "wx",
|
|
597
|
+
mode: 0o600,
|
|
598
|
+
});
|
|
599
|
+
return lockPath;
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (!hasErrorCode(error, "EEXIST")) {
|
|
603
|
+
throw error;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
const stats = statSync(lockPath);
|
|
607
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
608
|
+
if (ageMs > LOCK_TIMEOUT_MS) {
|
|
609
|
+
try {
|
|
610
|
+
unlinkSync(lockPath);
|
|
611
|
+
}
|
|
612
|
+
catch (staleError) {
|
|
613
|
+
if (!hasErrorCode(staleError, "ENOENT")) {
|
|
614
|
+
logWarn("Failed to remove stale accounts lock", staleError);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (statError) {
|
|
621
|
+
if (!hasErrorCode(statError, "ENOENT")) {
|
|
622
|
+
logWarn("Failed to inspect accounts lock file", statError);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
await sleep(waitMs);
|
|
626
|
+
waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
throw new Error("Accounts lock timeout");
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function releaseAccountsLock(lockPath) {
|
|
633
|
+
try {
|
|
634
|
+
unlinkSync(lockPath);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
if (!hasErrorCode(error, "ENOENT")) {
|
|
638
|
+
logWarn("Failed to release accounts lock file", error);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async function withAccountsStoreLock(mutator) {
|
|
644
|
+
const lockPath = await acquireAccountsLock();
|
|
645
|
+
try {
|
|
646
|
+
const store = loadAccountsStoreData();
|
|
647
|
+
const next = await mutator(store);
|
|
648
|
+
if (next && typeof next === "object") {
|
|
649
|
+
writeAccountsStoreData(next);
|
|
650
|
+
return next;
|
|
651
|
+
}
|
|
652
|
+
writeAccountsStoreData(store);
|
|
653
|
+
return store;
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
releaseAccountsLock(lockPath);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Requests device code from Qwen OAuth server
|
|
661
|
+
* Initiates OAuth 2.0 Device Authorization Grant flow
|
|
662
|
+
* @param {{ challenge: string, verifier: string }} pkce - PKCE challenge and verifier
|
|
663
|
+
* @returns {Promise<Object|null>} Device auth response or null on failure
|
|
664
|
+
*/
|
|
215
665
|
export async function requestDeviceCode(pkce) {
|
|
216
666
|
try {
|
|
217
667
|
const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
|
|
@@ -236,10 +686,12 @@ export async function requestDeviceCode(pkce) {
|
|
|
236
686
|
if (LOGGING_ENABLED) {
|
|
237
687
|
logInfo("Device code response received:", json);
|
|
238
688
|
}
|
|
689
|
+
// Validate required fields are present
|
|
239
690
|
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
|
240
691
|
logError("device code response missing fields:", json);
|
|
241
692
|
return null;
|
|
242
693
|
}
|
|
694
|
+
// Fix verification_uri_complete if missing client parameter
|
|
243
695
|
if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
|
|
244
696
|
const baseUrl = json.verification_uri_complete || json.verification_uri;
|
|
245
697
|
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
@@ -255,6 +707,14 @@ export async function requestDeviceCode(pkce) {
|
|
|
255
707
|
return null;
|
|
256
708
|
}
|
|
257
709
|
}
|
|
710
|
+
/**
|
|
711
|
+
* Polls Qwen OAuth server for access token using device code
|
|
712
|
+
* Implements OAuth 2.0 Device Flow polling with proper error handling
|
|
713
|
+
* @param {string} deviceCode - Device code from requestDeviceCode
|
|
714
|
+
* @param {string} verifier - PKCE code verifier
|
|
715
|
+
* @param {number} [interval=2] - Polling interval in seconds
|
|
716
|
+
* @returns {Promise<Object>} Token result object with type: success|pending|slow_down|failed|denied|expired
|
|
717
|
+
*/
|
|
258
718
|
export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
259
719
|
try {
|
|
260
720
|
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
@@ -274,6 +734,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
274
734
|
const json = await res.json().catch(() => ({}));
|
|
275
735
|
const errorCode = typeof json.error === "string" ? json.error : undefined;
|
|
276
736
|
const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
|
|
737
|
+
// Handle standard OAuth 2.0 Device Flow errors
|
|
277
738
|
if (errorCode === "authorization_pending") {
|
|
278
739
|
return { type: "pending" };
|
|
279
740
|
}
|
|
@@ -286,6 +747,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
286
747
|
if (errorCode === "access_denied") {
|
|
287
748
|
return { type: "denied" };
|
|
288
749
|
}
|
|
750
|
+
// Log and return fatal error for unknown errors
|
|
289
751
|
logError("token poll failed:", {
|
|
290
752
|
status: res.status,
|
|
291
753
|
error: errorCode,
|
|
@@ -309,6 +771,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
309
771
|
all_fields: Object.keys(json),
|
|
310
772
|
});
|
|
311
773
|
}
|
|
774
|
+
// Validate token response structure
|
|
312
775
|
if (!validateTokenResponse(json, "token response")) {
|
|
313
776
|
return {
|
|
314
777
|
type: "failed",
|
|
@@ -332,6 +795,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
332
795
|
catch (error) {
|
|
333
796
|
const message = error instanceof Error ? error.message : String(error);
|
|
334
797
|
const lowered = message.toLowerCase();
|
|
798
|
+
// Identify transient errors that may succeed on retry
|
|
335
799
|
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
336
800
|
logWarn("token poll failed:", { message, transient });
|
|
337
801
|
return {
|
|
@@ -341,6 +805,11 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
341
805
|
};
|
|
342
806
|
}
|
|
343
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Performs single token refresh attempt
|
|
810
|
+
* @param {string} refreshToken - Refresh token to use
|
|
811
|
+
* @returns {Promise<Object>} Token result object with type: success|failed
|
|
812
|
+
*/
|
|
344
813
|
async function refreshAccessTokenOnce(refreshToken) {
|
|
345
814
|
try {
|
|
346
815
|
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
@@ -360,6 +829,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
360
829
|
const lowered = text.toLowerCase();
|
|
361
830
|
const isUnauthorized = res.status === 401 || res.status === 403;
|
|
362
831
|
const isRateLimited = res.status === 429;
|
|
832
|
+
// Identify transient errors (5xx, timeout, network)
|
|
363
833
|
const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
|
|
364
834
|
logError("token refresh failed:", { status: res.status, text });
|
|
365
835
|
return {
|
|
@@ -379,6 +849,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
379
849
|
all_fields: Object.keys(json),
|
|
380
850
|
});
|
|
381
851
|
}
|
|
852
|
+
// Validate refresh response structure
|
|
382
853
|
if (!validateTokenResponse(json, "refresh response")) {
|
|
383
854
|
return {
|
|
384
855
|
type: "failed",
|
|
@@ -402,6 +873,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
402
873
|
catch (error) {
|
|
403
874
|
const message = error instanceof Error ? error.message : String(error);
|
|
404
875
|
const lowered = message.toLowerCase();
|
|
876
|
+
// Identify transient errors that may succeed on retry
|
|
405
877
|
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
406
878
|
logError("token refresh error:", { message, transient });
|
|
407
879
|
return {
|
|
@@ -411,33 +883,47 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
411
883
|
};
|
|
412
884
|
}
|
|
413
885
|
}
|
|
886
|
+
/**
|
|
887
|
+
* Refreshes access token using refresh token with lock coordination
|
|
888
|
+
* Implements retry logic for transient failures
|
|
889
|
+
* @param {string} refreshToken - Refresh token to use
|
|
890
|
+
* @returns {Promise<Object>} Token result object with type: success|failed
|
|
891
|
+
*/
|
|
414
892
|
export async function refreshAccessToken(refreshToken) {
|
|
893
|
+
// Acquire lock to prevent concurrent refresh operations
|
|
415
894
|
const lockPath = await acquireTokenLock();
|
|
416
895
|
try {
|
|
896
|
+
// Check if another process already refreshed the token
|
|
417
897
|
const latest = loadStoredToken();
|
|
418
898
|
if (latest && !isTokenExpired(latest.expiry_date)) {
|
|
419
899
|
return buildTokenSuccessFromStored(latest);
|
|
420
900
|
}
|
|
901
|
+
// Use latest refresh token if available
|
|
421
902
|
const effectiveRefreshToken = latest?.refresh_token || refreshToken;
|
|
903
|
+
// Retry loop for transient failures
|
|
422
904
|
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
|
423
905
|
const result = await refreshAccessTokenOnce(effectiveRefreshToken);
|
|
424
906
|
if (result.type === "success") {
|
|
425
907
|
saveToken(result);
|
|
426
908
|
return result;
|
|
427
909
|
}
|
|
910
|
+
// Non-retryable errors: 401/403 (unauthorized)
|
|
428
911
|
if (result.status === 401 || result.status === 403) {
|
|
429
912
|
logError(`Refresh token rejected (${result.status}), re-authentication required`);
|
|
430
913
|
clearStoredToken();
|
|
431
914
|
return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
|
|
432
915
|
}
|
|
916
|
+
// Non-retryable errors: 429 (rate limited)
|
|
433
917
|
if (result.status === 429) {
|
|
434
918
|
logError("Token refresh rate-limited (429), aborting retries");
|
|
435
919
|
return { type: "failed", status: 429, error: "rate_limited", fatal: true };
|
|
436
920
|
}
|
|
921
|
+
// Non-retryable errors: fatal flag set
|
|
437
922
|
if (result.fatal) {
|
|
438
923
|
logError("Token refresh failed with fatal error", result);
|
|
439
924
|
return result;
|
|
440
925
|
}
|
|
926
|
+
// Retry transient failures
|
|
441
927
|
if (attempt < MAX_REFRESH_RETRIES) {
|
|
442
928
|
if (LOGGING_ENABLED) {
|
|
443
929
|
logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
|
|
@@ -449,14 +935,24 @@ export async function refreshAccessToken(refreshToken) {
|
|
|
449
935
|
return { type: "failed", error: "refresh_failed" };
|
|
450
936
|
}
|
|
451
937
|
finally {
|
|
938
|
+
// Always release lock
|
|
452
939
|
releaseTokenLock(lockPath);
|
|
453
940
|
}
|
|
454
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* Generates PKCE challenge and verifier for OAuth flow
|
|
944
|
+
* @returns {Promise<{challenge: string, verifier: string}>} PKCE challenge and verifier pair
|
|
945
|
+
*/
|
|
455
946
|
export async function createPKCE() {
|
|
456
947
|
const { challenge, verifier } = await generatePKCE();
|
|
457
948
|
return { challenge, verifier };
|
|
458
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* Loads stored token from disk with legacy migration
|
|
952
|
+
* @returns {Object|null} Stored token data or null if not found/invalid
|
|
953
|
+
*/
|
|
459
954
|
export function loadStoredToken() {
|
|
955
|
+
// Migrate legacy token if needed
|
|
460
956
|
migrateLegacyTokenIfNeeded();
|
|
461
957
|
const tokenPath = getTokenPath();
|
|
462
958
|
if (!existsSync(tokenPath)) {
|
|
@@ -470,6 +966,7 @@ export function loadStoredToken() {
|
|
|
470
966
|
logWarn("Invalid token data, re-authentication required");
|
|
471
967
|
return null;
|
|
472
968
|
}
|
|
969
|
+
// Check if token file needs format update
|
|
473
970
|
const needsRewrite = typeof parsed.expiry_date !== "number" ||
|
|
474
971
|
typeof parsed.token_type !== "string" ||
|
|
475
972
|
typeof parsed.expires === "number" ||
|
|
@@ -489,6 +986,9 @@ export function loadStoredToken() {
|
|
|
489
986
|
return null;
|
|
490
987
|
}
|
|
491
988
|
}
|
|
989
|
+
/**
|
|
990
|
+
* Clears stored token from both current and legacy paths
|
|
991
|
+
*/
|
|
492
992
|
export function clearStoredToken() {
|
|
493
993
|
const targets = [getTokenPath(), getLegacyTokenPath()];
|
|
494
994
|
for (const tokenPath of targets) {
|
|
@@ -504,6 +1004,11 @@ export function clearStoredToken() {
|
|
|
504
1004
|
}
|
|
505
1005
|
}
|
|
506
1006
|
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Saves token result to disk
|
|
1009
|
+
* @param {{ type: string, access: string, refresh: string, expires: number, resourceUrl?: string }} tokenResult - Token result from OAuth flow
|
|
1010
|
+
* @throws {Error} If token result is invalid or write fails
|
|
1011
|
+
*/
|
|
507
1012
|
export function saveToken(tokenResult) {
|
|
508
1013
|
if (tokenResult.type !== "success") {
|
|
509
1014
|
throw new Error("Cannot save non-success token result");
|
|
@@ -523,14 +1028,236 @@ export function saveToken(tokenResult) {
|
|
|
523
1028
|
throw error;
|
|
524
1029
|
}
|
|
525
1030
|
}
|
|
1031
|
+
|
|
1032
|
+
function buildRuntimeAccountResponse(account, healthyCount, totalCount, accessToken, resourceUrl) {
|
|
1033
|
+
return {
|
|
1034
|
+
accountId: account.id,
|
|
1035
|
+
accessToken,
|
|
1036
|
+
resourceUrl: resourceUrl || account.resource_url,
|
|
1037
|
+
exhaustedUntil: account.exhaustedUntil || 0,
|
|
1038
|
+
healthyAccountCount: healthyCount,
|
|
1039
|
+
totalAccountCount: totalCount,
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
export async function upsertOAuthAccount(tokenResult, options = {}) {
|
|
1044
|
+
const tokenData = normalizeTokenResultToStored(tokenResult);
|
|
1045
|
+
if (!tokenData) {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
migrateLegacyTokenToAccountsIfNeeded();
|
|
1049
|
+
const accountKey = options.accountKey || deriveAccountKeyFromToken(tokenData);
|
|
1050
|
+
let selectedId = null;
|
|
1051
|
+
await withAccountsStoreLock((store) => {
|
|
1052
|
+
const now = Date.now();
|
|
1053
|
+
let index = -1;
|
|
1054
|
+
if (typeof options.accountId === "string" && options.accountId.length > 0) {
|
|
1055
|
+
index = store.accounts.findIndex(account => account.id === options.accountId);
|
|
1056
|
+
}
|
|
1057
|
+
if (index < 0 && accountKey) {
|
|
1058
|
+
index = store.accounts.findIndex(account => account.accountKey === accountKey);
|
|
1059
|
+
}
|
|
1060
|
+
if (index < 0) {
|
|
1061
|
+
index = store.accounts.findIndex(account => account.token?.refresh_token === tokenData.refresh_token);
|
|
1062
|
+
}
|
|
1063
|
+
if (index < 0) {
|
|
1064
|
+
const newId = typeof options.accountId === "string" && options.accountId.length > 0
|
|
1065
|
+
? options.accountId
|
|
1066
|
+
: `acct_${Date.now().toString(36)}_${Math.random().toString(16).slice(2, 10)}`;
|
|
1067
|
+
store.accounts.push(buildAccountEntry(tokenData, newId, accountKey));
|
|
1068
|
+
index = store.accounts.length - 1;
|
|
1069
|
+
}
|
|
1070
|
+
const target = store.accounts[index];
|
|
1071
|
+
target.token = tokenData;
|
|
1072
|
+
target.resource_url = tokenData.resource_url;
|
|
1073
|
+
target.exhaustedUntil = 0;
|
|
1074
|
+
target.lastErrorCode = undefined;
|
|
1075
|
+
target.updatedAt = now;
|
|
1076
|
+
if (!target.createdAt || !Number.isFinite(target.createdAt)) {
|
|
1077
|
+
target.createdAt = now;
|
|
1078
|
+
}
|
|
1079
|
+
if (accountKey) {
|
|
1080
|
+
target.accountKey = accountKey;
|
|
1081
|
+
}
|
|
1082
|
+
selectedId = target.id;
|
|
1083
|
+
if (options.setActive || !store.activeAccountId) {
|
|
1084
|
+
store.activeAccountId = target.id;
|
|
1085
|
+
}
|
|
1086
|
+
return store;
|
|
1087
|
+
});
|
|
1088
|
+
if (!selectedId) {
|
|
1089
|
+
return null;
|
|
1090
|
+
}
|
|
1091
|
+
if (options.setActive) {
|
|
1092
|
+
return getActiveOAuthAccount({ allowExhausted: true, preferredAccountId: selectedId });
|
|
1093
|
+
}
|
|
1094
|
+
return getActiveOAuthAccount({ allowExhausted: true });
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
export async function getActiveOAuthAccount(options = {}) {
|
|
1098
|
+
migrateLegacyTokenToAccountsIfNeeded();
|
|
1099
|
+
const lockPath = await acquireAccountsLock();
|
|
1100
|
+
let selected = null;
|
|
1101
|
+
let dirty = false;
|
|
1102
|
+
try {
|
|
1103
|
+
const store = loadAccountsStoreData();
|
|
1104
|
+
const now = Date.now();
|
|
1105
|
+
if (store.accounts.length === 0) {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
if (typeof options.preferredAccountId === "string" && options.preferredAccountId.length > 0) {
|
|
1109
|
+
const exists = store.accounts.some(account => account.id === options.preferredAccountId);
|
|
1110
|
+
if (exists && store.activeAccountId !== options.preferredAccountId) {
|
|
1111
|
+
store.activeAccountId = options.preferredAccountId;
|
|
1112
|
+
dirty = true;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
let active = store.accounts.find(account => account.id === store.activeAccountId);
|
|
1116
|
+
if (!active) {
|
|
1117
|
+
active = store.accounts[0];
|
|
1118
|
+
store.activeAccountId = active.id;
|
|
1119
|
+
dirty = true;
|
|
1120
|
+
}
|
|
1121
|
+
const activeHealthy = !(typeof active.exhaustedUntil === "number" && active.exhaustedUntil > now);
|
|
1122
|
+
if (!activeHealthy && !options.allowExhausted) {
|
|
1123
|
+
const replacement = pickNextHealthyAccount(store, new Set(), now);
|
|
1124
|
+
if (!replacement) {
|
|
1125
|
+
return null;
|
|
1126
|
+
}
|
|
1127
|
+
if (store.activeAccountId !== replacement.id) {
|
|
1128
|
+
store.activeAccountId = replacement.id;
|
|
1129
|
+
dirty = true;
|
|
1130
|
+
}
|
|
1131
|
+
active = replacement;
|
|
1132
|
+
}
|
|
1133
|
+
const healthyCount = countHealthyAccounts(store, now);
|
|
1134
|
+
selected = {
|
|
1135
|
+
account: { ...active },
|
|
1136
|
+
healthyCount,
|
|
1137
|
+
totalCount: store.accounts.length,
|
|
1138
|
+
};
|
|
1139
|
+
if (dirty) {
|
|
1140
|
+
writeAccountsStoreData(store);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
finally {
|
|
1144
|
+
releaseAccountsLock(lockPath);
|
|
1145
|
+
}
|
|
1146
|
+
if (!selected) {
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
if (options.requireHealthy && selected.account.exhaustedUntil > Date.now()) {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
try {
|
|
1153
|
+
syncAccountToLegacyTokenFile(selected.account);
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
logWarn("Failed to sync active account token to oauth_creds.json", error);
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
const valid = await getValidToken();
|
|
1160
|
+
if (!valid) {
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
const latest = loadStoredToken();
|
|
1164
|
+
if (latest) {
|
|
1165
|
+
try {
|
|
1166
|
+
await withAccountsStoreLock((store) => {
|
|
1167
|
+
const target = store.accounts.find(account => account.id === selected.account.id);
|
|
1168
|
+
if (!target) {
|
|
1169
|
+
return store;
|
|
1170
|
+
}
|
|
1171
|
+
target.token = latest;
|
|
1172
|
+
target.resource_url = latest.resource_url;
|
|
1173
|
+
target.updatedAt = Date.now();
|
|
1174
|
+
return store;
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
catch (error) {
|
|
1178
|
+
logWarn("Failed to update account token from refreshed legacy token", error);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return buildRuntimeAccountResponse(selected.account, selected.healthyCount, selected.totalCount, valid.accessToken, valid.resourceUrl);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
export async function markOAuthAccountQuotaExhausted(accountId, errorCode = "insufficient_quota") {
|
|
1185
|
+
if (typeof accountId !== "string" || accountId.length === 0) {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
migrateLegacyTokenToAccountsIfNeeded();
|
|
1189
|
+
const cooldownMs = getQuotaCooldownMs();
|
|
1190
|
+
let outcome = null;
|
|
1191
|
+
await withAccountsStoreLock((store) => {
|
|
1192
|
+
const now = Date.now();
|
|
1193
|
+
const target = store.accounts.find(account => account.id === accountId);
|
|
1194
|
+
if (!target) {
|
|
1195
|
+
return store;
|
|
1196
|
+
}
|
|
1197
|
+
target.exhaustedUntil = now + cooldownMs;
|
|
1198
|
+
target.lastErrorCode = errorCode;
|
|
1199
|
+
target.updatedAt = now;
|
|
1200
|
+
if (store.activeAccountId === target.id) {
|
|
1201
|
+
const next = pickNextHealthyAccount(store, new Set([target.id]), now);
|
|
1202
|
+
if (next) {
|
|
1203
|
+
store.activeAccountId = next.id;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
outcome = {
|
|
1207
|
+
accountId: target.id,
|
|
1208
|
+
exhaustedUntil: target.exhaustedUntil,
|
|
1209
|
+
healthyAccountCount: countHealthyAccounts(store, now),
|
|
1210
|
+
totalAccountCount: store.accounts.length,
|
|
1211
|
+
};
|
|
1212
|
+
return store;
|
|
1213
|
+
});
|
|
1214
|
+
return outcome;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
export async function switchToNextHealthyOAuthAccount(excludedAccountIds = []) {
|
|
1218
|
+
migrateLegacyTokenToAccountsIfNeeded();
|
|
1219
|
+
const excluded = new Set(Array.isArray(excludedAccountIds)
|
|
1220
|
+
? excludedAccountIds.filter(id => typeof id === "string" && id.length > 0)
|
|
1221
|
+
: []);
|
|
1222
|
+
let switchedId = null;
|
|
1223
|
+
await withAccountsStoreLock((store) => {
|
|
1224
|
+
const next = pickNextHealthyAccount(store, excluded, Date.now());
|
|
1225
|
+
if (!next) {
|
|
1226
|
+
return store;
|
|
1227
|
+
}
|
|
1228
|
+
store.activeAccountId = next.id;
|
|
1229
|
+
switchedId = next.id;
|
|
1230
|
+
return store;
|
|
1231
|
+
});
|
|
1232
|
+
if (!switchedId) {
|
|
1233
|
+
return null;
|
|
1234
|
+
}
|
|
1235
|
+
return getActiveOAuthAccount({
|
|
1236
|
+
allowExhausted: false,
|
|
1237
|
+
requireHealthy: true,
|
|
1238
|
+
preferredAccountId: switchedId,
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Checks if token is expired (with buffer)
|
|
1244
|
+
* @param {number} expiresAt - Token expiry timestamp in milliseconds
|
|
1245
|
+
* @returns {boolean} True if token is expired or expiring soon
|
|
1246
|
+
*/
|
|
526
1247
|
export function isTokenExpired(expiresAt) {
|
|
527
1248
|
return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
528
1249
|
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Gets valid access token, refreshing if expired
|
|
1253
|
+
* @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
|
|
1254
|
+
*/
|
|
529
1255
|
export async function getValidToken() {
|
|
530
1256
|
const stored = loadStoredToken();
|
|
531
1257
|
if (!stored) {
|
|
532
1258
|
return null;
|
|
533
1259
|
}
|
|
1260
|
+
// Return cached token if still valid
|
|
534
1261
|
if (!isTokenExpired(stored.expiry_date)) {
|
|
535
1262
|
return {
|
|
536
1263
|
accessToken: stored.access_token,
|
|
@@ -540,6 +1267,7 @@ export async function getValidToken() {
|
|
|
540
1267
|
if (LOGGING_ENABLED) {
|
|
541
1268
|
logInfo("Token expired, refreshing...");
|
|
542
1269
|
}
|
|
1270
|
+
// Token expired, try to refresh
|
|
543
1271
|
const refreshResult = await refreshAccessToken(stored.refresh_token);
|
|
544
1272
|
if (refreshResult.type !== "success") {
|
|
545
1273
|
logError("Token refresh failed, re-authentication required");
|
|
@@ -551,6 +1279,12 @@ export async function getValidToken() {
|
|
|
551
1279
|
resourceUrl: refreshResult.resourceUrl,
|
|
552
1280
|
};
|
|
553
1281
|
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Constructs DashScope API base URL from resource_url
|
|
1285
|
+
* @param {string} [resourceUrl] - Resource URL from token (optional)
|
|
1286
|
+
* @returns {string} DashScope API base URL
|
|
1287
|
+
*/
|
|
554
1288
|
export function getApiBaseUrl(resourceUrl) {
|
|
555
1289
|
if (resourceUrl) {
|
|
556
1290
|
try {
|
|
@@ -564,6 +1298,7 @@ export function getApiBaseUrl(resourceUrl) {
|
|
|
564
1298
|
logWarn("Invalid resource_url protocol, using default DashScope endpoint");
|
|
565
1299
|
return DEFAULT_QWEN_BASE_URL;
|
|
566
1300
|
}
|
|
1301
|
+
// Ensure URL ends with /v1 suffix
|
|
567
1302
|
let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
|
|
568
1303
|
const suffix = "/v1";
|
|
569
1304
|
if (!baseUrl.endsWith(suffix)) {
|