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.
- package/dist/index.js +235 -143
- package/dist/lib/auth/auth.js +338 -170
- package/dist/lib/auth/browser.js +2 -2
- package/dist/lib/config.d.ts +13 -1
- package/dist/lib/config.js +22 -4
- package/dist/lib/constants.d.ts +3 -3
- package/dist/lib/constants.js +4 -4
- package/dist/lib/types.d.ts +8 -2
- package/package.json +1 -1
package/dist/lib/auth/auth.js
CHANGED
|
@@ -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,
|
|
4
|
+
import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } from "../config.js";
|
|
5
5
|
import { logError, logWarn, logInfo, LOGGING_ENABLED } from "../logger.js";
|
|
6
|
-
|
|
7
|
-
const MAX_REFRESH_RETRIES = 2;
|
|
6
|
+
const MAX_REFRESH_RETRIES = 1;
|
|
8
7
|
const REFRESH_RETRY_DELAY_MS = 1000;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
217
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
|
|
63
218
|
method: "POST",
|
|
64
|
-
headers: {
|
|
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
|
|
260
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
112
261
|
method: "POST",
|
|
113
|
-
headers: {
|
|
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
|
|
124
|
-
|
|
125
|
-
if (
|
|
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 (
|
|
280
|
+
if (errorCode === "slow_down") {
|
|
129
281
|
return { type: "slow_down" };
|
|
130
282
|
}
|
|
131
|
-
if (
|
|
283
|
+
if (errorCode === "expired_token") {
|
|
132
284
|
return { type: "expired" };
|
|
133
285
|
}
|
|
134
|
-
if (
|
|
286
|
+
if (errorCode === "access_denied") {
|
|
135
287
|
return { type: "denied" };
|
|
136
288
|
}
|
|
137
|
-
logError("token poll failed:", {
|
|
138
|
-
|
|
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 {
|
|
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,
|
|
329
|
+
resourceUrl: json.resource_url,
|
|
167
330
|
};
|
|
168
331
|
}
|
|
169
332
|
catch (error) {
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
346
|
+
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
182
347
|
method: "POST",
|
|
183
|
-
headers: {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
if (!
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
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(
|
|
500
|
+
logWarn(`Deleted token file: ${tokenPath}`);
|
|
296
501
|
}
|
|
297
502
|
catch (error) {
|
|
298
|
-
logError("
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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;
|
|
532
|
+
return null;
|
|
350
533
|
}
|
|
351
|
-
|
|
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
|
|
390
|
-
if (!
|
|
391
|
-
logWarn(
|
|
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
|
-
|
|
395
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|