opencode-qwen-cli-auth 2.2.1 → 2.2.3

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.
@@ -1,27 +1,52 @@
1
1
  import { generatePKCE } from "@openauthjs/openauth/pkce";
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync, statSync } from "fs";
3
3
  import { QWEN_OAUTH, DEFAULT_QWEN_BASE_URL, TOKEN_REFRESH_BUFFER_MS, VERIFICATION_URI } from "../constants.js";
4
- import { getTokenPath, getConfigDir } from "../config.js";
4
+ import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
5
5
  import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
6
- // So lan retry toi da khi refresh token that bai
7
- const MAX_REFRESH_RETRIES = 2;
6
+ const MAX_REFRESH_RETRIES = 1;
8
7
  const REFRESH_RETRY_DELAY_MS = 1000;
9
- /**
10
- * Normalize and validate resource_url from OAuth response
11
- * @param resourceUrl - Resource URL from token response
12
- * @returns Normalized URL or undefined if invalid
13
- */
8
+ const OAUTH_REQUEST_TIMEOUT_MS = 15000;
9
+ const LOCK_TIMEOUT_MS = 10000;
10
+ const LOCK_ATTEMPT_INTERVAL_MS = 100;
11
+ const LOCK_BACKOFF_MULTIPLIER = 1.5;
12
+ const LOCK_MAX_INTERVAL_MS = 2000;
13
+ const LOCK_MAX_ATTEMPTS = 20;
14
+ function isAbortError(error) {
15
+ return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
16
+ }
17
+ function hasErrorCode(error, code) {
18
+ return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
19
+ }
20
+ function sleep(ms) {
21
+ return new Promise(resolve => setTimeout(resolve, ms));
22
+ }
23
+ async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
24
+ const controller = new AbortController();
25
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
26
+ try {
27
+ return await fetch(url, {
28
+ ...init,
29
+ signal: controller.signal,
30
+ });
31
+ }
32
+ catch (error) {
33
+ if (isAbortError(error)) {
34
+ throw new Error(`OAuth request timed out after ${timeoutMs}ms`);
35
+ }
36
+ throw error;
37
+ }
38
+ finally {
39
+ clearTimeout(timeoutId);
40
+ }
41
+ }
14
42
  function normalizeResourceUrl(resourceUrl) {
15
43
  if (!resourceUrl)
16
44
  return undefined;
17
45
  try {
18
- // Qwen returns resource_url without protocol (e.g., "portal.qwen.ai")
19
- // Normalize it by adding https:// if missing
20
46
  let normalizedUrl = resourceUrl;
21
47
  if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
22
48
  normalizedUrl = `https://${normalizedUrl}`;
23
49
  }
24
- // Validate the normalized URL
25
50
  new URL(normalizedUrl);
26
51
  if (LOGGING_ENABLED) {
27
52
  logInfo("Valid resource_url found and normalized:", normalizedUrl);
@@ -33,35 +58,168 @@ function normalizeResourceUrl(resourceUrl) {
33
58
  return undefined;
34
59
  }
35
60
  }
36
- /**
37
- * Validate token response fields
38
- * @param json - Token response JSON
39
- * @param context - Context for logging (e.g., "token response" or "refresh response")
40
- * @returns True if valid, false otherwise
41
- */
42
61
  function validateTokenResponse(json, context) {
43
- // Check required fields
44
- if (!json.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
45
- logError(`${context} missing fields:`, json);
62
+ if (!json.access_token || typeof json.access_token !== "string") {
63
+ logError(`${context} missing access_token`);
46
64
  return false;
47
65
  }
48
- // Validate expires_in is positive
49
- if (json.expires_in <= 0) {
50
- logError(`invalid expires_in value in ${context}:`, json.expires_in);
66
+ if (!json.refresh_token || typeof json.refresh_token !== "string") {
67
+ logError(`${context} missing refresh_token`);
68
+ return false;
69
+ }
70
+ if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
71
+ logError(`${context} invalid expires_in:`, json.expires_in);
51
72
  return false;
52
73
  }
53
74
  return true;
54
75
  }
55
- /**
56
- * Request device authorization code
57
- * @param pkce - PKCE challenge/verifier pair
58
- * @returns Device authorization response with user code and verification URL
59
- */
76
+ function toStoredTokenData(data) {
77
+ if (!data || typeof data !== "object") {
78
+ return null;
79
+ }
80
+ const raw = data;
81
+ const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
82
+ const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
83
+ const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
84
+ const expiryDate = typeof raw.expiry_date === "number"
85
+ ? raw.expiry_date
86
+ : typeof raw.expires === "number"
87
+ ? raw.expires
88
+ : typeof raw.expiry_date === "string"
89
+ ? Number(raw.expiry_date)
90
+ : undefined;
91
+ const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
92
+ if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
93
+ return null;
94
+ }
95
+ return {
96
+ access_token: accessToken,
97
+ refresh_token: refreshToken,
98
+ token_type: tokenType,
99
+ expiry_date: expiryDate,
100
+ resource_url: resourceUrl,
101
+ };
102
+ }
103
+ function buildTokenSuccessFromStored(stored) {
104
+ return {
105
+ type: "success",
106
+ access: stored.access_token,
107
+ refresh: stored.refresh_token,
108
+ expires: stored.expiry_date,
109
+ resourceUrl: stored.resource_url,
110
+ };
111
+ }
112
+ function writeStoredTokenData(tokenData) {
113
+ const qwenDir = getQwenDir();
114
+ if (!existsSync(qwenDir)) {
115
+ mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
116
+ }
117
+ const tokenPath = getTokenPath();
118
+ const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
119
+ try {
120
+ writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
121
+ encoding: "utf-8",
122
+ mode: 0o600,
123
+ });
124
+ renameSync(tempPath, tokenPath);
125
+ }
126
+ catch (error) {
127
+ try {
128
+ if (existsSync(tempPath)) {
129
+ unlinkSync(tempPath);
130
+ }
131
+ }
132
+ catch (_cleanupError) {
133
+ }
134
+ throw error;
135
+ }
136
+ }
137
+ function migrateLegacyTokenIfNeeded() {
138
+ const tokenPath = getTokenPath();
139
+ if (existsSync(tokenPath)) {
140
+ return;
141
+ }
142
+ const legacyPath = getLegacyTokenPath();
143
+ if (!existsSync(legacyPath)) {
144
+ return;
145
+ }
146
+ try {
147
+ const legacyRaw = readFileSync(legacyPath, "utf-8");
148
+ const legacyData = JSON.parse(legacyRaw);
149
+ const converted = toStoredTokenData(legacyData);
150
+ if (!converted) {
151
+ logWarn("Legacy token found but invalid, skipping migration");
152
+ return;
153
+ }
154
+ writeStoredTokenData(converted);
155
+ logInfo("Migrated token from legacy path to ~/.qwen/oauth_creds.json");
156
+ }
157
+ catch (error) {
158
+ logWarn("Failed to migrate legacy token:", error);
159
+ }
160
+ }
161
+ async function acquireTokenLock() {
162
+ const lockPath = getTokenLockPath();
163
+ const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
164
+ let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
165
+ for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
166
+ try {
167
+ writeFileSync(lockPath, lockValue, {
168
+ encoding: "utf-8",
169
+ flag: "wx",
170
+ mode: 0o600,
171
+ });
172
+ return lockPath;
173
+ }
174
+ catch (error) {
175
+ if (!hasErrorCode(error, "EEXIST")) {
176
+ throw error;
177
+ }
178
+ try {
179
+ const stats = statSync(lockPath);
180
+ const ageMs = Date.now() - stats.mtimeMs;
181
+ if (ageMs > LOCK_TIMEOUT_MS) {
182
+ try {
183
+ unlinkSync(lockPath);
184
+ logWarn("Removed stale token lock file", { lockPath, ageMs });
185
+ }
186
+ catch (staleError) {
187
+ if (!hasErrorCode(staleError, "ENOENT")) {
188
+ logWarn("Failed to remove stale token lock", staleError);
189
+ }
190
+ }
191
+ continue;
192
+ }
193
+ }
194
+ catch (statError) {
195
+ if (!hasErrorCode(statError, "ENOENT")) {
196
+ logWarn("Failed to inspect token lock file", statError);
197
+ }
198
+ }
199
+ await sleep(waitMs);
200
+ waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
201
+ }
202
+ }
203
+ throw new Error("Token refresh lock timeout");
204
+ }
205
+ function releaseTokenLock(lockPath) {
206
+ try {
207
+ unlinkSync(lockPath);
208
+ }
209
+ catch (error) {
210
+ if (!hasErrorCode(error, "ENOENT")) {
211
+ logWarn("Failed to release token lock file", error);
212
+ }
213
+ }
214
+ }
60
215
  export async function requestDeviceCode(pkce) {
61
216
  try {
62
- const res = await fetch(QWEN_OAUTH.DEVICE_CODE_URL, {
217
+ const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
63
218
  method: "POST",
64
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
219
+ headers: {
220
+ "Content-Type": "application/x-www-form-urlencoded",
221
+ Accept: "application/json",
222
+ },
65
223
  body: new URLSearchParams({
66
224
  client_id: QWEN_OAUTH.CLIENT_ID,
67
225
  scope: QWEN_OAUTH.SCOPE,
@@ -82,11 +240,9 @@ export async function requestDeviceCode(pkce) {
82
240
  logError("device code response missing fields:", json);
83
241
  return null;
84
242
  }
85
- // Ensure verification_uri_complete includes the client parameter
86
- // Qwen's OAuth server requires client=qwen-code for proper authentication
87
243
  if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
88
244
  const baseUrl = json.verification_uri_complete || json.verification_uri;
89
- const separator = baseUrl.includes('?') ? '&' : '?';
245
+ const separator = baseUrl.includes("?") ? "&" : "?";
90
246
  json.verification_uri_complete = `${baseUrl}${separator}${VERIFICATION_URI.CLIENT_PARAM_VALUE}`;
91
247
  if (LOGGING_ENABLED) {
92
248
  logInfo("Fixed verification_uri_complete:", json.verification_uri_complete);
@@ -99,18 +255,14 @@ export async function requestDeviceCode(pkce) {
99
255
  return null;
100
256
  }
101
257
  }
102
- /**
103
- * Poll for token using device code
104
- * @param deviceCode - Device code from authorization response
105
- * @param verifier - PKCE verifier
106
- * @param interval - Polling interval in seconds (from device response)
107
- * @returns Token result or null if still pending
108
- */
109
258
  export async function pollForToken(deviceCode, verifier, interval = 2) {
110
259
  try {
111
- const res = await fetch(QWEN_OAUTH.TOKEN_URL, {
260
+ const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
112
261
  method: "POST",
113
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
262
+ headers: {
263
+ "Content-Type": "application/x-www-form-urlencoded",
264
+ Accept: "application/json",
265
+ },
114
266
  body: new URLSearchParams({
115
267
  grant_type: QWEN_OAUTH.GRANT_TYPE_DEVICE,
116
268
  client_id: QWEN_OAUTH.CLIENT_ID,
@@ -120,26 +272,35 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
120
272
  });
121
273
  if (!res.ok) {
122
274
  const json = await res.json().catch(() => ({}));
123
- const error = json.error;
124
- // Handle expected errors
125
- if (error === "authorization_pending") {
275
+ const errorCode = typeof json.error === "string" ? json.error : undefined;
276
+ const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
277
+ if (errorCode === "authorization_pending") {
126
278
  return { type: "pending" };
127
279
  }
128
- if (error === "slow_down") {
280
+ if (errorCode === "slow_down") {
129
281
  return { type: "slow_down" };
130
282
  }
131
- if (error === "expired_token") {
283
+ if (errorCode === "expired_token") {
132
284
  return { type: "expired" };
133
285
  }
134
- if (error === "access_denied") {
286
+ if (errorCode === "access_denied") {
135
287
  return { type: "denied" };
136
288
  }
137
- logError("token poll failed:", { status: res.status, json });
138
- return { type: "failed" };
289
+ logError("token poll failed:", {
290
+ status: res.status,
291
+ error: errorCode,
292
+ description: errorDescription,
293
+ });
294
+ return {
295
+ type: "failed",
296
+ status: res.status,
297
+ error: errorCode || "unknown_error",
298
+ description: errorDescription,
299
+ fatal: true,
300
+ };
139
301
  }
140
302
  const json = await res.json();
141
303
  if (LOGGING_ENABLED) {
142
- // Log the full token response for debugging
143
304
  logInfo("Token response received:", {
144
305
  has_access_token: !!json.access_token,
145
306
  has_refresh_token: !!json.refresh_token,
@@ -148,39 +309,46 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
148
309
  all_fields: Object.keys(json),
149
310
  });
150
311
  }
151
- // Validate token response fields
152
312
  if (!validateTokenResponse(json, "token response")) {
153
- return { type: "failed" };
313
+ return {
314
+ type: "failed",
315
+ error: "invalid_token_response",
316
+ description: "Token response missing required fields",
317
+ fatal: true,
318
+ };
154
319
  }
155
- // Validate and normalize resource_url if present
156
320
  json.resource_url = normalizeResourceUrl(json.resource_url);
157
321
  if (!json.resource_url) {
158
322
  logWarn("No valid resource_url in token response, will use default DashScope endpoint");
159
323
  }
160
- // At this point, validation ensures these fields exist
161
324
  return {
162
325
  type: "success",
163
326
  access: json.access_token,
164
327
  refresh: json.refresh_token,
165
328
  expires: Date.now() + json.expires_in * 1000,
166
- resourceUrl: json.resource_url, // Dynamic API base URL
329
+ resourceUrl: json.resource_url,
167
330
  };
168
331
  }
169
332
  catch (error) {
170
- logError("token poll error:", error);
171
- return { type: "failed" };
333
+ const message = error instanceof Error ? error.message : String(error);
334
+ const lowered = message.toLowerCase();
335
+ const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
336
+ logWarn("token poll failed:", { message, transient });
337
+ return {
338
+ type: "failed",
339
+ error: message,
340
+ fatal: !transient,
341
+ };
172
342
  }
173
343
  }
174
- /**
175
- * Refresh access token using refresh token (1 lan duy nhat, khong retry)
176
- * @param refreshToken - Refresh token
177
- * @returns Token result
178
- */
179
344
  async function refreshAccessTokenOnce(refreshToken) {
180
345
  try {
181
- const res = await fetch(QWEN_OAUTH.TOKEN_URL, {
346
+ const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
182
347
  method: "POST",
183
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
348
+ headers: {
349
+ "Content-Type": "application/x-www-form-urlencoded",
350
+ Accept: "application/json",
351
+ },
184
352
  body: new URLSearchParams({
185
353
  grant_type: QWEN_OAUTH.GRANT_TYPE_REFRESH,
186
354
  client_id: QWEN_OAUTH.CLIENT_ID,
@@ -189,8 +357,17 @@ async function refreshAccessTokenOnce(refreshToken) {
189
357
  });
190
358
  if (!res.ok) {
191
359
  const text = await res.text().catch(() => "");
360
+ const lowered = text.toLowerCase();
361
+ const isUnauthorized = res.status === 401 || res.status === 403;
362
+ const isRateLimited = res.status === 429;
363
+ const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
192
364
  logError("token refresh failed:", { status: res.status, text });
193
- return { type: "failed", status: res.status };
365
+ return {
366
+ type: "failed",
367
+ status: res.status,
368
+ error: text || `HTTP ${res.status}`,
369
+ fatal: isUnauthorized || isRateLimited || !transient,
370
+ };
194
371
  }
195
372
  const json = await res.json();
196
373
  if (LOGGING_ENABLED) {
@@ -202,11 +379,14 @@ async function refreshAccessTokenOnce(refreshToken) {
202
379
  all_fields: Object.keys(json),
203
380
  });
204
381
  }
205
- // Validate token response fields
206
382
  if (!validateTokenResponse(json, "refresh response")) {
207
- return { type: "failed" };
383
+ return {
384
+ type: "failed",
385
+ error: "invalid_refresh_response",
386
+ description: "Refresh response missing required fields",
387
+ fatal: true,
388
+ };
208
389
  }
209
- // Validate and normalize resource_url if present
210
390
  json.resource_url = normalizeResourceUrl(json.resource_url);
211
391
  if (!json.resource_url) {
212
392
  logWarn("No valid resource_url in refresh response, will use default DashScope endpoint");
@@ -220,200 +400,188 @@ async function refreshAccessTokenOnce(refreshToken) {
220
400
  };
221
401
  }
222
402
  catch (error) {
223
- logError("token refresh error:", error);
224
- return { type: "failed" };
403
+ const message = error instanceof Error ? error.message : String(error);
404
+ const lowered = message.toLowerCase();
405
+ const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
406
+ logError("token refresh error:", { message, transient });
407
+ return {
408
+ type: "failed",
409
+ error: message,
410
+ fatal: !transient,
411
+ };
225
412
  }
226
413
  }
227
- /**
228
- * Refresh access token voi retry logic
229
- * Retry toi da MAX_REFRESH_RETRIES lan voi delay giua cac lan
230
- * @param refreshToken - Refresh token
231
- * @returns Token result
232
- */
233
414
  export async function refreshAccessToken(refreshToken) {
234
- for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
235
- const result = await refreshAccessTokenOnce(refreshToken);
236
- if (result.type === "success") {
237
- return result;
238
- }
239
- // Neu loi 401/403 thi refresh token da bi revoke, khong can retry
240
- if (result.status === 401 || result.status === 403) {
241
- logError("Refresh token bi reject (" + result.status + "), can dang nhap lai");
242
- return { type: "failed" };
415
+ const lockPath = await acquireTokenLock();
416
+ try {
417
+ const latest = loadStoredToken();
418
+ if (latest && !isTokenExpired(latest.expiry_date)) {
419
+ return buildTokenSuccessFromStored(latest);
243
420
  }
244
- // Con retry thi cho delay roi thu lai
245
- if (attempt < MAX_REFRESH_RETRIES) {
246
- if (LOGGING_ENABLED) {
247
- logInfo(`Token refresh that bai, thu lai lan ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
421
+ const effectiveRefreshToken = latest?.refresh_token || refreshToken;
422
+ for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
423
+ const result = await refreshAccessTokenOnce(effectiveRefreshToken);
424
+ if (result.type === "success") {
425
+ saveToken(result);
426
+ return result;
427
+ }
428
+ if (result.status === 401 || result.status === 403) {
429
+ logError(`Refresh token rejected (${result.status}), re-authentication required`);
430
+ clearStoredToken();
431
+ return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
432
+ }
433
+ if (result.status === 429) {
434
+ logError("Token refresh rate-limited (429), aborting retries");
435
+ return { type: "failed", status: 429, error: "rate_limited", fatal: true };
436
+ }
437
+ if (result.fatal) {
438
+ logError("Token refresh failed with fatal error", result);
439
+ return result;
440
+ }
441
+ if (attempt < MAX_REFRESH_RETRIES) {
442
+ if (LOGGING_ENABLED) {
443
+ logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
444
+ }
445
+ await sleep(REFRESH_RETRY_DELAY_MS);
248
446
  }
249
- await new Promise(resolve => setTimeout(resolve, REFRESH_RETRY_DELAY_MS));
250
447
  }
448
+ logError("Token refresh failed after retry limit");
449
+ return { type: "failed", error: "refresh_failed" };
450
+ }
451
+ finally {
452
+ releaseTokenLock(lockPath);
251
453
  }
252
- logError("Token refresh that bai sau " + (MAX_REFRESH_RETRIES + 1) + " lan thu");
253
- return { type: "failed" };
254
454
  }
255
- /**
256
- * Generate PKCE challenge and verifier
257
- * @returns PKCE pair
258
- */
259
455
  export async function createPKCE() {
260
456
  const { challenge, verifier } = await generatePKCE();
261
457
  return { challenge, verifier };
262
458
  }
263
- /**
264
- * Load stored token from disk
265
- * @returns Stored token data or null if not found
266
- */
267
459
  export function loadStoredToken() {
460
+ migrateLegacyTokenIfNeeded();
268
461
  const tokenPath = getTokenPath();
269
462
  if (!existsSync(tokenPath)) {
270
463
  return null;
271
464
  }
272
465
  try {
273
466
  const content = readFileSync(tokenPath, "utf-8");
274
- const data = JSON.parse(content);
275
- // Validate required fields
276
- if (!data.access_token || !data.refresh_token || typeof data.expires !== "number") {
467
+ const parsed = JSON.parse(content);
468
+ const normalized = toStoredTokenData(parsed);
469
+ if (!normalized) {
277
470
  logWarn("Invalid token data, re-authentication required");
278
471
  return null;
279
472
  }
280
- return data;
473
+ const needsRewrite = typeof parsed.expiry_date !== "number" ||
474
+ typeof parsed.token_type !== "string" ||
475
+ typeof parsed.expires === "number" ||
476
+ parsed.resource_url !== normalized.resource_url;
477
+ if (needsRewrite) {
478
+ try {
479
+ writeStoredTokenData(normalized);
480
+ }
481
+ catch (rewriteError) {
482
+ logWarn("Failed to normalize token file format:", rewriteError);
483
+ }
484
+ }
485
+ return normalized;
281
486
  }
282
487
  catch (error) {
283
488
  logError("Failed to load token:", error);
284
489
  return null;
285
490
  }
286
491
  }
287
- /**
288
- * Xoa token luu tren disk khi token khong con hop le
289
- */
290
492
  export function clearStoredToken() {
291
- const tokenPath = getTokenPath();
292
- if (existsSync(tokenPath)) {
493
+ const targets = [getTokenPath(), getLegacyTokenPath()];
494
+ for (const tokenPath of targets) {
495
+ if (!existsSync(tokenPath)) {
496
+ continue;
497
+ }
293
498
  try {
294
499
  unlinkSync(tokenPath);
295
- logWarn("Da xoa token cu, can dang nhap lai");
500
+ logWarn(`Deleted token file: ${tokenPath}`);
296
501
  }
297
502
  catch (error) {
298
- logError("Khong the xoa token file:", error);
503
+ logError("Unable to delete token file:", { tokenPath, error });
299
504
  }
300
505
  }
301
506
  }
302
- /**
303
- * Save token to disk
304
- * @param tokenResult - Token result from OAuth flow
305
- */
306
507
  export function saveToken(tokenResult) {
307
508
  if (tokenResult.type !== "success") {
308
509
  throw new Error("Cannot save non-success token result");
309
510
  }
310
- const configDir = getConfigDir();
311
- // Ensure directory exists
312
- if (!existsSync(configDir)) {
313
- mkdirSync(configDir, { recursive: true, mode: 0o700 });
314
- }
315
511
  const tokenData = {
316
512
  access_token: tokenResult.access,
317
513
  refresh_token: tokenResult.refresh,
318
- expires: tokenResult.expires,
319
- resource_url: tokenResult.resourceUrl,
514
+ token_type: "Bearer",
515
+ expiry_date: tokenResult.expires,
516
+ resource_url: normalizeResourceUrl(tokenResult.resourceUrl),
320
517
  };
321
- const tokenPath = getTokenPath();
322
518
  try {
323
- writeFileSync(tokenPath, JSON.stringify(tokenData, null, 2), {
324
- encoding: "utf-8",
325
- mode: 0o600, // Secure permissions
326
- });
519
+ writeStoredTokenData(tokenData);
327
520
  }
328
521
  catch (error) {
329
522
  logError("Failed to save token:", error);
330
523
  throw error;
331
524
  }
332
525
  }
333
- /**
334
- * Check if token is expired (with 5 minute buffer)
335
- * @param expiresAt - Expiration timestamp in milliseconds
336
- * @returns True if token is expired or will expire soon
337
- */
338
526
  export function isTokenExpired(expiresAt) {
339
527
  return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
340
528
  }
341
- /**
342
- * Get valid access token, refreshing if necessary
343
- * Khi refresh that bai, xoa token cu de user biet can dang nhap lai
344
- * @returns Access token and resource URL, or null if authentication required
345
- */
346
529
  export async function getValidToken() {
347
530
  const stored = loadStoredToken();
348
531
  if (!stored) {
349
- return null; // Khong co token, can dang nhap
532
+ return null;
350
533
  }
351
- // Token con hieu luc
352
- if (!isTokenExpired(stored.expires)) {
534
+ if (!isTokenExpired(stored.expiry_date)) {
353
535
  return {
354
536
  accessToken: stored.access_token,
355
537
  resourceUrl: stored.resource_url,
356
538
  };
357
539
  }
358
- // Token het han, thu refresh (co retry ben trong)
359
540
  if (LOGGING_ENABLED) {
360
541
  logInfo("Token expired, refreshing...");
361
542
  }
362
543
  const refreshResult = await refreshAccessToken(stored.refresh_token);
363
544
  if (refreshResult.type !== "success") {
364
545
  logError("Token refresh failed, re-authentication required");
365
- // Xoa token cu de tranh loop loi
366
546
  clearStoredToken();
367
547
  return null;
368
548
  }
369
- // Luu token moi
370
- saveToken(refreshResult);
371
549
  return {
372
550
  accessToken: refreshResult.access,
373
551
  resourceUrl: refreshResult.resourceUrl,
374
552
  };
375
553
  }
376
- /**
377
- * Get Portal API base URL from token or use default
378
- * @param resourceUrl - Resource URL from token (optional)
379
- * @returns Portal API base URL
380
- *
381
- * IMPORTANT: Portal API uses /v1 path (not /api/v1)
382
- * - OAuth endpoints: /api/v1/oauth2/ (for authentication)
383
- * - Chat API: /v1/ (for completions)
384
- */
385
554
  export function getApiBaseUrl(resourceUrl) {
386
555
  if (resourceUrl) {
387
- // Validate URL format
388
556
  try {
389
- const url = new URL(resourceUrl);
390
- if (!url.protocol.startsWith('http')) {
391
- logWarn('Invalid resource_url protocol, using default Portal API URL');
557
+ const normalizedResourceUrl = normalizeResourceUrl(resourceUrl);
558
+ if (!normalizedResourceUrl) {
559
+ logWarn("Invalid resource_url, using default Portal API URL");
560
+ return DEFAULT_QWEN_BASE_URL;
561
+ }
562
+ const url = new URL(normalizedResourceUrl);
563
+ if (!url.protocol.startsWith("http")) {
564
+ logWarn("Invalid resource_url protocol, using default Portal API URL");
392
565
  return DEFAULT_QWEN_BASE_URL;
393
566
  }
394
- // Construct the Portal API endpoint from resource_url
395
- // Qwen returns "portal.qwen.ai" which should become "https://portal.qwen.ai/v1"
396
- // Remove trailing slash if present
397
- let baseUrl = resourceUrl.replace(/\/$/, "");
398
- // Add /v1 suffix if not already present
399
- const suffix = '/v1';
567
+ let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
568
+ const suffix = "/v1";
400
569
  if (!baseUrl.endsWith(suffix)) {
401
570
  baseUrl = `${baseUrl}${suffix}`;
402
571
  }
403
572
  if (LOGGING_ENABLED) {
404
- logInfo('Constructed Portal API base URL from resource_url:', baseUrl);
573
+ logInfo("Constructed Portal API base URL from resource_url:", baseUrl);
405
574
  }
406
575
  return baseUrl;
407
576
  }
408
577
  catch (error) {
409
- logWarn('Invalid resource_url format, using default Portal API URL:', error);
578
+ logWarn("Invalid resource_url format, using default Portal API URL:", error);
410
579
  return DEFAULT_QWEN_BASE_URL;
411
580
  }
412
581
  }
413
- // Fall back to default Portal API URL
414
582
  if (LOGGING_ENABLED) {
415
- logInfo('No resource_url provided, using default Portal API URL');
583
+ logInfo("No resource_url provided, using default Portal API URL");
416
584
  }
417
585
  return DEFAULT_QWEN_BASE_URL;
418
586
  }
419
- //# sourceMappingURL=auth.js.map
587
+ //# sourceMappingURL=auth.js.map