opencode-qwen-cli-auth 2.2.9 → 2.3.0
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 +261 -62
- package/README.vi.md +261 -0
- package/dist/index.js +270 -79
- package/dist/lib/auth/auth.js +192 -2
- package/dist/lib/auth/browser.js +14 -4
- package/dist/lib/config.js +34 -0
- 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,69 @@
|
|
|
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
11
|
import { getTokenPath, getQwenDir, getTokenLockPath, getLegacyTokenPath } 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
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Checks if an error is an AbortError (from AbortController)
|
|
33
|
+
* @param {*} error - The error to check
|
|
34
|
+
* @returns {boolean} True if error is an AbortError
|
|
35
|
+
*/
|
|
14
36
|
function isAbortError(error) {
|
|
15
37
|
return typeof error === "object" && error !== null && ("name" in error) && error.name === "AbortError";
|
|
16
38
|
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks if an error has a specific error code (for Node.js system errors)
|
|
42
|
+
* @param {*} error - The error to check
|
|
43
|
+
* @param {string} code - The error code to look for (e.g., "EEXIST", "ENOENT")
|
|
44
|
+
* @returns {boolean} True if error has the specified code
|
|
45
|
+
*/
|
|
17
46
|
function hasErrorCode(error, code) {
|
|
18
47
|
return typeof error === "object" && error !== null && ("code" in error) && error.code === code;
|
|
19
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a promise that resolves after specified milliseconds
|
|
52
|
+
* @param {number} ms - Milliseconds to sleep
|
|
53
|
+
* @returns {Promise<void>} Promise that resolves after delay
|
|
54
|
+
*/
|
|
20
55
|
function sleep(ms) {
|
|
21
56
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Performs fetch with timeout using AbortController
|
|
60
|
+
* Automatically aborts request if it exceeds timeout
|
|
61
|
+
* @param {string} url - URL to fetch
|
|
62
|
+
* @param {RequestInit} [init] - Fetch options
|
|
63
|
+
* @param {number} [timeoutMs=OAUTH_REQUEST_TIMEOUT_MS] - Timeout in milliseconds
|
|
64
|
+
* @returns {Promise<Response>} Fetch response
|
|
65
|
+
* @throws {Error} If request times out
|
|
66
|
+
*/
|
|
23
67
|
async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS) {
|
|
24
68
|
const controller = new AbortController();
|
|
25
69
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -39,14 +83,23 @@ async function fetchWithTimeout(url, init, timeoutMs = OAUTH_REQUEST_TIMEOUT_MS)
|
|
|
39
83
|
clearTimeout(timeoutId);
|
|
40
84
|
}
|
|
41
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Normalizes resource URL to valid HTTPS URL format
|
|
89
|
+
* Adds https:// prefix if missing and validates URL format
|
|
90
|
+
* @param {string|undefined} resourceUrl - URL to normalize
|
|
91
|
+
* @returns {string|undefined} Normalized URL or undefined if invalid
|
|
92
|
+
*/
|
|
42
93
|
function normalizeResourceUrl(resourceUrl) {
|
|
43
94
|
if (!resourceUrl)
|
|
44
95
|
return undefined;
|
|
45
96
|
try {
|
|
46
97
|
let normalizedUrl = resourceUrl;
|
|
98
|
+
// Add https:// prefix if protocol is missing
|
|
47
99
|
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
|
48
100
|
normalizedUrl = `https://${normalizedUrl}`;
|
|
49
101
|
}
|
|
102
|
+
// Validate URL format
|
|
50
103
|
new URL(normalizedUrl);
|
|
51
104
|
if (LOGGING_ENABLED) {
|
|
52
105
|
logInfo("Valid resource_url found and normalized:", normalizedUrl);
|
|
@@ -58,21 +111,37 @@ function normalizeResourceUrl(resourceUrl) {
|
|
|
58
111
|
return undefined;
|
|
59
112
|
}
|
|
60
113
|
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validates OAuth token response has required fields
|
|
117
|
+
* @param {Object} json - Token response JSON
|
|
118
|
+
* @param {string} context - Context for error messages (e.g., "token response", "refresh response")
|
|
119
|
+
* @returns {boolean} True if response is valid
|
|
120
|
+
*/
|
|
61
121
|
function validateTokenResponse(json, context) {
|
|
122
|
+
// Check access_token exists and is string
|
|
62
123
|
if (!json.access_token || typeof json.access_token !== "string") {
|
|
63
124
|
logError(`${context} missing access_token`);
|
|
64
125
|
return false;
|
|
65
126
|
}
|
|
127
|
+
// Check refresh_token exists and is string
|
|
66
128
|
if (!json.refresh_token || typeof json.refresh_token !== "string") {
|
|
67
129
|
logError(`${context} missing refresh_token`);
|
|
68
130
|
return false;
|
|
69
131
|
}
|
|
132
|
+
// Check expires_in is valid positive number
|
|
70
133
|
if (typeof json.expires_in !== "number" || json.expires_in <= 0) {
|
|
71
134
|
logError(`${context} invalid expires_in:`, json.expires_in);
|
|
72
135
|
return false;
|
|
73
136
|
}
|
|
74
137
|
return true;
|
|
75
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Converts raw token data to standardized stored token format
|
|
141
|
+
* Handles different field name variations (expiry_date vs expires)
|
|
142
|
+
* @param {Object} data - Raw token data from OAuth response or file
|
|
143
|
+
* @returns {Object|null} Normalized token data or null if invalid
|
|
144
|
+
*/
|
|
76
145
|
function toStoredTokenData(data) {
|
|
77
146
|
if (!data || typeof data !== "object") {
|
|
78
147
|
return null;
|
|
@@ -81,6 +150,7 @@ function toStoredTokenData(data) {
|
|
|
81
150
|
const accessToken = typeof raw.access_token === "string" ? raw.access_token : undefined;
|
|
82
151
|
const refreshToken = typeof raw.refresh_token === "string" ? raw.refresh_token : undefined;
|
|
83
152
|
const tokenType = typeof raw.token_type === "string" && raw.token_type.length > 0 ? raw.token_type : "Bearer";
|
|
153
|
+
// Handle both expiry_date and expires field names
|
|
84
154
|
const expiryDate = typeof raw.expiry_date === "number"
|
|
85
155
|
? raw.expiry_date
|
|
86
156
|
: typeof raw.expires === "number"
|
|
@@ -89,6 +159,7 @@ function toStoredTokenData(data) {
|
|
|
89
159
|
? Number(raw.expiry_date)
|
|
90
160
|
: undefined;
|
|
91
161
|
const resourceUrl = typeof raw.resource_url === "string" ? normalizeResourceUrl(raw.resource_url) : undefined;
|
|
162
|
+
// Validate all required fields are present and valid
|
|
92
163
|
if (!accessToken || !refreshToken || typeof expiryDate !== "number" || !Number.isFinite(expiryDate) || expiryDate <= 0) {
|
|
93
164
|
return null;
|
|
94
165
|
}
|
|
@@ -100,6 +171,12 @@ function toStoredTokenData(data) {
|
|
|
100
171
|
resource_url: resourceUrl,
|
|
101
172
|
};
|
|
102
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Builds token success object from stored token data
|
|
177
|
+
* @param {Object} stored - Stored token data from file
|
|
178
|
+
* @returns {Object} Token success object for SDK
|
|
179
|
+
*/
|
|
103
180
|
function buildTokenSuccessFromStored(stored) {
|
|
104
181
|
return {
|
|
105
182
|
type: "success",
|
|
@@ -109,12 +186,20 @@ function buildTokenSuccessFromStored(stored) {
|
|
|
109
186
|
resourceUrl: stored.resource_url,
|
|
110
187
|
};
|
|
111
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Writes token data to disk atomically using temp file + rename
|
|
191
|
+
* Uses secure file permissions (0o600 - owner read/write only)
|
|
192
|
+
* @param {Object} tokenData - Token data to write
|
|
193
|
+
* @throws {Error} If write operation fails
|
|
194
|
+
*/
|
|
112
195
|
function writeStoredTokenData(tokenData) {
|
|
113
196
|
const qwenDir = getQwenDir();
|
|
197
|
+
// Create directory if it doesn't exist with secure permissions
|
|
114
198
|
if (!existsSync(qwenDir)) {
|
|
115
199
|
mkdirSync(qwenDir, { recursive: true, mode: 0o700 });
|
|
116
200
|
}
|
|
117
201
|
const tokenPath = getTokenPath();
|
|
202
|
+
// Use atomic write: write to temp file then rename
|
|
118
203
|
const tempPath = `${tokenPath}.tmp.${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
|
119
204
|
try {
|
|
120
205
|
writeFileSync(tempPath, JSON.stringify(tokenData, null, 2), {
|
|
@@ -124,6 +209,7 @@ function writeStoredTokenData(tokenData) {
|
|
|
124
209
|
renameSync(tempPath, tokenPath);
|
|
125
210
|
}
|
|
126
211
|
catch (error) {
|
|
212
|
+
// Clean up temp file on error
|
|
127
213
|
try {
|
|
128
214
|
if (existsSync(tempPath)) {
|
|
129
215
|
unlinkSync(tempPath);
|
|
@@ -134,12 +220,19 @@ function writeStoredTokenData(tokenData) {
|
|
|
134
220
|
throw error;
|
|
135
221
|
}
|
|
136
222
|
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Migrates legacy token from old plugin location to new location
|
|
226
|
+
* Checks if new token file exists, if not tries to migrate from legacy path
|
|
227
|
+
*/
|
|
137
228
|
function migrateLegacyTokenIfNeeded() {
|
|
138
229
|
const tokenPath = getTokenPath();
|
|
230
|
+
// Skip if new token file already exists
|
|
139
231
|
if (existsSync(tokenPath)) {
|
|
140
232
|
return;
|
|
141
233
|
}
|
|
142
234
|
const legacyPath = getLegacyTokenPath();
|
|
235
|
+
// Skip if legacy file doesn't exist
|
|
143
236
|
if (!existsSync(legacyPath)) {
|
|
144
237
|
return;
|
|
145
238
|
}
|
|
@@ -158,12 +251,19 @@ function migrateLegacyTokenIfNeeded() {
|
|
|
158
251
|
logWarn("Failed to migrate legacy token:", error);
|
|
159
252
|
}
|
|
160
253
|
}
|
|
254
|
+
/**
|
|
255
|
+
* Acquires exclusive lock for token refresh to prevent concurrent refreshes
|
|
256
|
+
* Uses file-based locking with exponential backoff retry strategy
|
|
257
|
+
* @returns {Promise<string>} Lock file path if acquired successfully
|
|
258
|
+
* @throws {Error} If lock cannot be acquired within timeout
|
|
259
|
+
*/
|
|
161
260
|
async function acquireTokenLock() {
|
|
162
261
|
const lockPath = getTokenLockPath();
|
|
163
262
|
const lockValue = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
164
263
|
let waitMs = LOCK_ATTEMPT_INTERVAL_MS;
|
|
165
264
|
for (let attempt = 0; attempt < LOCK_MAX_ATTEMPTS; attempt++) {
|
|
166
265
|
try {
|
|
266
|
+
// Try to create lock file with exclusive flag
|
|
167
267
|
writeFileSync(lockPath, lockValue, {
|
|
168
268
|
encoding: "utf-8",
|
|
169
269
|
flag: "wx",
|
|
@@ -172,12 +272,14 @@ async function acquireTokenLock() {
|
|
|
172
272
|
return lockPath;
|
|
173
273
|
}
|
|
174
274
|
catch (error) {
|
|
275
|
+
// EEXIST means lock file already exists
|
|
175
276
|
if (!hasErrorCode(error, "EEXIST")) {
|
|
176
277
|
throw error;
|
|
177
278
|
}
|
|
178
279
|
try {
|
|
179
280
|
const stats = statSync(lockPath);
|
|
180
281
|
const ageMs = Date.now() - stats.mtimeMs;
|
|
282
|
+
// Remove stale lock if it's older than timeout
|
|
181
283
|
if (ageMs > LOCK_TIMEOUT_MS) {
|
|
182
284
|
try {
|
|
183
285
|
unlinkSync(lockPath);
|
|
@@ -196,22 +298,36 @@ async function acquireTokenLock() {
|
|
|
196
298
|
logWarn("Failed to inspect token lock file", statError);
|
|
197
299
|
}
|
|
198
300
|
}
|
|
301
|
+
// Wait with exponential backoff before retry
|
|
199
302
|
await sleep(waitMs);
|
|
200
303
|
waitMs = Math.min(Math.floor(waitMs * LOCK_BACKOFF_MULTIPLIER), LOCK_MAX_INTERVAL_MS);
|
|
201
304
|
}
|
|
202
305
|
}
|
|
203
306
|
throw new Error("Token refresh lock timeout");
|
|
204
307
|
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Releases token refresh lock
|
|
311
|
+
* Silently ignores errors if lock file doesn't exist
|
|
312
|
+
* @param {string} lockPath - Path to lock file to release
|
|
313
|
+
*/
|
|
205
314
|
function releaseTokenLock(lockPath) {
|
|
206
315
|
try {
|
|
207
316
|
unlinkSync(lockPath);
|
|
208
317
|
}
|
|
209
318
|
catch (error) {
|
|
319
|
+
// Ignore ENOENT (file not found) errors
|
|
210
320
|
if (!hasErrorCode(error, "ENOENT")) {
|
|
211
321
|
logWarn("Failed to release token lock file", error);
|
|
212
322
|
}
|
|
213
323
|
}
|
|
214
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Requests device code from Qwen OAuth server
|
|
327
|
+
* Initiates OAuth 2.0 Device Authorization Grant flow
|
|
328
|
+
* @param {{ challenge: string, verifier: string }} pkce - PKCE challenge and verifier
|
|
329
|
+
* @returns {Promise<Object|null>} Device auth response or null on failure
|
|
330
|
+
*/
|
|
215
331
|
export async function requestDeviceCode(pkce) {
|
|
216
332
|
try {
|
|
217
333
|
const res = await fetchWithTimeout(QWEN_OAUTH.DEVICE_CODE_URL, {
|
|
@@ -236,10 +352,12 @@ export async function requestDeviceCode(pkce) {
|
|
|
236
352
|
if (LOGGING_ENABLED) {
|
|
237
353
|
logInfo("Device code response received:", json);
|
|
238
354
|
}
|
|
355
|
+
// Validate required fields are present
|
|
239
356
|
if (!json.device_code || !json.user_code || !json.verification_uri) {
|
|
240
357
|
logError("device code response missing fields:", json);
|
|
241
358
|
return null;
|
|
242
359
|
}
|
|
360
|
+
// Fix verification_uri_complete if missing client parameter
|
|
243
361
|
if (!json.verification_uri_complete || !json.verification_uri_complete.includes(VERIFICATION_URI.CLIENT_PARAM_KEY)) {
|
|
244
362
|
const baseUrl = json.verification_uri_complete || json.verification_uri;
|
|
245
363
|
const separator = baseUrl.includes("?") ? "&" : "?";
|
|
@@ -255,6 +373,14 @@ export async function requestDeviceCode(pkce) {
|
|
|
255
373
|
return null;
|
|
256
374
|
}
|
|
257
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Polls Qwen OAuth server for access token using device code
|
|
378
|
+
* Implements OAuth 2.0 Device Flow polling with proper error handling
|
|
379
|
+
* @param {string} deviceCode - Device code from requestDeviceCode
|
|
380
|
+
* @param {string} verifier - PKCE code verifier
|
|
381
|
+
* @param {number} [interval=2] - Polling interval in seconds
|
|
382
|
+
* @returns {Promise<Object>} Token result object with type: success|pending|slow_down|failed|denied|expired
|
|
383
|
+
*/
|
|
258
384
|
export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
259
385
|
try {
|
|
260
386
|
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
@@ -274,6 +400,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
274
400
|
const json = await res.json().catch(() => ({}));
|
|
275
401
|
const errorCode = typeof json.error === "string" ? json.error : undefined;
|
|
276
402
|
const errorDescription = typeof json.error_description === "string" ? json.error_description : "No details provided";
|
|
403
|
+
// Handle standard OAuth 2.0 Device Flow errors
|
|
277
404
|
if (errorCode === "authorization_pending") {
|
|
278
405
|
return { type: "pending" };
|
|
279
406
|
}
|
|
@@ -286,6 +413,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
286
413
|
if (errorCode === "access_denied") {
|
|
287
414
|
return { type: "denied" };
|
|
288
415
|
}
|
|
416
|
+
// Log and return fatal error for unknown errors
|
|
289
417
|
logError("token poll failed:", {
|
|
290
418
|
status: res.status,
|
|
291
419
|
error: errorCode,
|
|
@@ -309,6 +437,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
309
437
|
all_fields: Object.keys(json),
|
|
310
438
|
});
|
|
311
439
|
}
|
|
440
|
+
// Validate token response structure
|
|
312
441
|
if (!validateTokenResponse(json, "token response")) {
|
|
313
442
|
return {
|
|
314
443
|
type: "failed",
|
|
@@ -332,6 +461,7 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
332
461
|
catch (error) {
|
|
333
462
|
const message = error instanceof Error ? error.message : String(error);
|
|
334
463
|
const lowered = message.toLowerCase();
|
|
464
|
+
// Identify transient errors that may succeed on retry
|
|
335
465
|
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
336
466
|
logWarn("token poll failed:", { message, transient });
|
|
337
467
|
return {
|
|
@@ -341,6 +471,11 @@ export async function pollForToken(deviceCode, verifier, interval = 2) {
|
|
|
341
471
|
};
|
|
342
472
|
}
|
|
343
473
|
}
|
|
474
|
+
/**
|
|
475
|
+
* Performs single token refresh attempt
|
|
476
|
+
* @param {string} refreshToken - Refresh token to use
|
|
477
|
+
* @returns {Promise<Object>} Token result object with type: success|failed
|
|
478
|
+
*/
|
|
344
479
|
async function refreshAccessTokenOnce(refreshToken) {
|
|
345
480
|
try {
|
|
346
481
|
const res = await fetchWithTimeout(QWEN_OAUTH.TOKEN_URL, {
|
|
@@ -360,6 +495,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
360
495
|
const lowered = text.toLowerCase();
|
|
361
496
|
const isUnauthorized = res.status === 401 || res.status === 403;
|
|
362
497
|
const isRateLimited = res.status === 429;
|
|
498
|
+
// Identify transient errors (5xx, timeout, network)
|
|
363
499
|
const transient = res.status >= 500 || lowered.includes("timed out") || lowered.includes("network");
|
|
364
500
|
logError("token refresh failed:", { status: res.status, text });
|
|
365
501
|
return {
|
|
@@ -379,6 +515,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
379
515
|
all_fields: Object.keys(json),
|
|
380
516
|
});
|
|
381
517
|
}
|
|
518
|
+
// Validate refresh response structure
|
|
382
519
|
if (!validateTokenResponse(json, "refresh response")) {
|
|
383
520
|
return {
|
|
384
521
|
type: "failed",
|
|
@@ -402,6 +539,7 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
402
539
|
catch (error) {
|
|
403
540
|
const message = error instanceof Error ? error.message : String(error);
|
|
404
541
|
const lowered = message.toLowerCase();
|
|
542
|
+
// Identify transient errors that may succeed on retry
|
|
405
543
|
const transient = lowered.includes("timed out") || lowered.includes("network") || lowered.includes("fetch");
|
|
406
544
|
logError("token refresh error:", { message, transient });
|
|
407
545
|
return {
|
|
@@ -411,33 +549,47 @@ async function refreshAccessTokenOnce(refreshToken) {
|
|
|
411
549
|
};
|
|
412
550
|
}
|
|
413
551
|
}
|
|
552
|
+
/**
|
|
553
|
+
* Refreshes access token using refresh token with lock coordination
|
|
554
|
+
* Implements retry logic for transient failures
|
|
555
|
+
* @param {string} refreshToken - Refresh token to use
|
|
556
|
+
* @returns {Promise<Object>} Token result object with type: success|failed
|
|
557
|
+
*/
|
|
414
558
|
export async function refreshAccessToken(refreshToken) {
|
|
559
|
+
// Acquire lock to prevent concurrent refresh operations
|
|
415
560
|
const lockPath = await acquireTokenLock();
|
|
416
561
|
try {
|
|
562
|
+
// Check if another process already refreshed the token
|
|
417
563
|
const latest = loadStoredToken();
|
|
418
564
|
if (latest && !isTokenExpired(latest.expiry_date)) {
|
|
419
565
|
return buildTokenSuccessFromStored(latest);
|
|
420
566
|
}
|
|
567
|
+
// Use latest refresh token if available
|
|
421
568
|
const effectiveRefreshToken = latest?.refresh_token || refreshToken;
|
|
569
|
+
// Retry loop for transient failures
|
|
422
570
|
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
|
423
571
|
const result = await refreshAccessTokenOnce(effectiveRefreshToken);
|
|
424
572
|
if (result.type === "success") {
|
|
425
573
|
saveToken(result);
|
|
426
574
|
return result;
|
|
427
575
|
}
|
|
576
|
+
// Non-retryable errors: 401/403 (unauthorized)
|
|
428
577
|
if (result.status === 401 || result.status === 403) {
|
|
429
578
|
logError(`Refresh token rejected (${result.status}), re-authentication required`);
|
|
430
579
|
clearStoredToken();
|
|
431
580
|
return { type: "failed", status: result.status, error: "refresh_token_rejected", fatal: true };
|
|
432
581
|
}
|
|
582
|
+
// Non-retryable errors: 429 (rate limited)
|
|
433
583
|
if (result.status === 429) {
|
|
434
584
|
logError("Token refresh rate-limited (429), aborting retries");
|
|
435
585
|
return { type: "failed", status: 429, error: "rate_limited", fatal: true };
|
|
436
586
|
}
|
|
587
|
+
// Non-retryable errors: fatal flag set
|
|
437
588
|
if (result.fatal) {
|
|
438
589
|
logError("Token refresh failed with fatal error", result);
|
|
439
590
|
return result;
|
|
440
591
|
}
|
|
592
|
+
// Retry transient failures
|
|
441
593
|
if (attempt < MAX_REFRESH_RETRIES) {
|
|
442
594
|
if (LOGGING_ENABLED) {
|
|
443
595
|
logInfo(`Token refresh transient failure, retrying attempt ${attempt + 2}/${MAX_REFRESH_RETRIES + 1}...`);
|
|
@@ -449,14 +601,24 @@ export async function refreshAccessToken(refreshToken) {
|
|
|
449
601
|
return { type: "failed", error: "refresh_failed" };
|
|
450
602
|
}
|
|
451
603
|
finally {
|
|
604
|
+
// Always release lock
|
|
452
605
|
releaseTokenLock(lockPath);
|
|
453
606
|
}
|
|
454
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Generates PKCE challenge and verifier for OAuth flow
|
|
610
|
+
* @returns {Promise<{challenge: string, verifier: string}>} PKCE challenge and verifier pair
|
|
611
|
+
*/
|
|
455
612
|
export async function createPKCE() {
|
|
456
613
|
const { challenge, verifier } = await generatePKCE();
|
|
457
614
|
return { challenge, verifier };
|
|
458
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Loads stored token from disk with legacy migration
|
|
618
|
+
* @returns {Object|null} Stored token data or null if not found/invalid
|
|
619
|
+
*/
|
|
459
620
|
export function loadStoredToken() {
|
|
621
|
+
// Migrate legacy token if needed
|
|
460
622
|
migrateLegacyTokenIfNeeded();
|
|
461
623
|
const tokenPath = getTokenPath();
|
|
462
624
|
if (!existsSync(tokenPath)) {
|
|
@@ -470,6 +632,7 @@ export function loadStoredToken() {
|
|
|
470
632
|
logWarn("Invalid token data, re-authentication required");
|
|
471
633
|
return null;
|
|
472
634
|
}
|
|
635
|
+
// Check if token file needs format update
|
|
473
636
|
const needsRewrite = typeof parsed.expiry_date !== "number" ||
|
|
474
637
|
typeof parsed.token_type !== "string" ||
|
|
475
638
|
typeof parsed.expires === "number" ||
|
|
@@ -489,6 +652,9 @@ export function loadStoredToken() {
|
|
|
489
652
|
return null;
|
|
490
653
|
}
|
|
491
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Clears stored token from both current and legacy paths
|
|
657
|
+
*/
|
|
492
658
|
export function clearStoredToken() {
|
|
493
659
|
const targets = [getTokenPath(), getLegacyTokenPath()];
|
|
494
660
|
for (const tokenPath of targets) {
|
|
@@ -504,6 +670,11 @@ export function clearStoredToken() {
|
|
|
504
670
|
}
|
|
505
671
|
}
|
|
506
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Saves token result to disk
|
|
675
|
+
* @param {{ type: string, access: string, refresh: string, expires: number, resourceUrl?: string }} tokenResult - Token result from OAuth flow
|
|
676
|
+
* @throws {Error} If token result is invalid or write fails
|
|
677
|
+
*/
|
|
507
678
|
export function saveToken(tokenResult) {
|
|
508
679
|
if (tokenResult.type !== "success") {
|
|
509
680
|
throw new Error("Cannot save non-success token result");
|
|
@@ -523,14 +694,25 @@ export function saveToken(tokenResult) {
|
|
|
523
694
|
throw error;
|
|
524
695
|
}
|
|
525
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* Checks if token is expired (with buffer)
|
|
699
|
+
* @param {number} expiresAt - Token expiry timestamp in milliseconds
|
|
700
|
+
* @returns {boolean} True if token is expired or expiring soon
|
|
701
|
+
*/
|
|
526
702
|
export function isTokenExpired(expiresAt) {
|
|
527
703
|
return Date.now() >= expiresAt - TOKEN_REFRESH_BUFFER_MS;
|
|
528
704
|
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Gets valid access token, refreshing if expired
|
|
708
|
+
* @returns {Promise<{ accessToken: string, resourceUrl?: string }|null>} Valid token or null if unavailable
|
|
709
|
+
*/
|
|
529
710
|
export async function getValidToken() {
|
|
530
711
|
const stored = loadStoredToken();
|
|
531
712
|
if (!stored) {
|
|
532
713
|
return null;
|
|
533
714
|
}
|
|
715
|
+
// Return cached token if still valid
|
|
534
716
|
if (!isTokenExpired(stored.expiry_date)) {
|
|
535
717
|
return {
|
|
536
718
|
accessToken: stored.access_token,
|
|
@@ -540,6 +722,7 @@ export async function getValidToken() {
|
|
|
540
722
|
if (LOGGING_ENABLED) {
|
|
541
723
|
logInfo("Token expired, refreshing...");
|
|
542
724
|
}
|
|
725
|
+
// Token expired, try to refresh
|
|
543
726
|
const refreshResult = await refreshAccessToken(stored.refresh_token);
|
|
544
727
|
if (refreshResult.type !== "success") {
|
|
545
728
|
logError("Token refresh failed, re-authentication required");
|
|
@@ -551,6 +734,12 @@ export async function getValidToken() {
|
|
|
551
734
|
resourceUrl: refreshResult.resourceUrl,
|
|
552
735
|
};
|
|
553
736
|
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Constructs DashScope API base URL from resource_url
|
|
740
|
+
* @param {string} [resourceUrl] - Resource URL from token (optional)
|
|
741
|
+
* @returns {string} DashScope API base URL
|
|
742
|
+
*/
|
|
554
743
|
export function getApiBaseUrl(resourceUrl) {
|
|
555
744
|
if (resourceUrl) {
|
|
556
745
|
try {
|
|
@@ -564,6 +753,7 @@ export function getApiBaseUrl(resourceUrl) {
|
|
|
564
753
|
logWarn("Invalid resource_url protocol, using default DashScope endpoint");
|
|
565
754
|
return DEFAULT_QWEN_BASE_URL;
|
|
566
755
|
}
|
|
756
|
+
// Ensure URL ends with /v1 suffix
|
|
567
757
|
let baseUrl = normalizedResourceUrl.replace(/\/$/, "");
|
|
568
758
|
const suffix = "/v1";
|
|
569
759
|
if (!baseUrl.endsWith(suffix)) {
|
package/dist/lib/auth/browser.js
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser utilities for OAuth flow
|
|
3
|
-
* Handles platform-specific browser opening
|
|
2
|
+
* @fileoverview Browser utilities for OAuth flow
|
|
3
|
+
* Handles platform-specific browser opening for OAuth authorization URL
|
|
4
|
+
* @license MIT
|
|
4
5
|
*/
|
|
6
|
+
|
|
5
7
|
import { spawn } from "node:child_process";
|
|
6
8
|
import { PLATFORM_OPENERS } from "../constants.js";
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
11
|
* Gets the platform-specific command to open a URL in the default browser
|
|
9
|
-
* @returns Browser opener command for the current platform
|
|
12
|
+
* @returns {string} Browser opener command for the current platform (darwin: 'open', win32: 'start', linux: 'xdg-open')
|
|
10
13
|
*/
|
|
11
14
|
export function getBrowserOpener() {
|
|
12
15
|
const platform = process.platform;
|
|
16
|
+
// macOS uses 'open' command
|
|
13
17
|
if (platform === "darwin")
|
|
14
18
|
return PLATFORM_OPENERS.darwin;
|
|
19
|
+
// Windows uses 'start' command
|
|
15
20
|
if (platform === "win32")
|
|
16
21
|
return PLATFORM_OPENERS.win32;
|
|
22
|
+
// Linux uses 'xdg-open' command
|
|
17
23
|
return PLATFORM_OPENERS.linux;
|
|
18
24
|
}
|
|
25
|
+
|
|
19
26
|
/**
|
|
20
27
|
* Opens a URL in the default browser
|
|
21
28
|
* Silently fails if browser cannot be opened (user can copy URL manually)
|
|
22
|
-
* @param url - URL to open
|
|
29
|
+
* @param {string} url - The URL to open in browser (typically OAuth verification URL)
|
|
30
|
+
* @returns {void}
|
|
23
31
|
*/
|
|
24
32
|
export function openBrowserUrl(url) {
|
|
25
33
|
try {
|
|
26
34
|
const opener = getBrowserOpener();
|
|
35
|
+
// Spawn browser process with detached stdio to avoid blocking
|
|
27
36
|
spawn(opener, [url], {
|
|
28
37
|
stdio: "ignore",
|
|
38
|
+
// Use shell on Windows for 'start' command to work properly
|
|
29
39
|
shell: process.platform === "win32",
|
|
30
40
|
});
|
|
31
41
|
}
|
package/dist/lib/config.js
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Configuration utilities for Qwen OAuth Plugin
|
|
3
|
+
* Manages paths for configuration, tokens, and cache directories
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import { homedir } from "os";
|
|
2
8
|
import { join } from "path";
|
|
3
9
|
import { readFileSync, existsSync } from "fs";
|
|
10
|
+
|
|
4
11
|
/**
|
|
5
12
|
* Get plugin configuration directory
|
|
13
|
+
* @returns {string} Path to ~/.opencode/qwen/
|
|
6
14
|
*/
|
|
7
15
|
export function getConfigDir() {
|
|
8
16
|
return join(homedir(), ".opencode", "qwen");
|
|
9
17
|
}
|
|
18
|
+
|
|
10
19
|
/**
|
|
11
20
|
* Get Qwen CLI credential directory (~/.qwen)
|
|
21
|
+
* This directory is shared with the official qwen-code CLI for token storage
|
|
22
|
+
* @returns {string} Path to ~/.qwen/
|
|
12
23
|
*/
|
|
13
24
|
export function getQwenDir() {
|
|
14
25
|
return join(homedir(), ".qwen");
|
|
15
26
|
}
|
|
27
|
+
|
|
16
28
|
/**
|
|
17
29
|
* Get plugin configuration file path
|
|
30
|
+
* @returns {string} Path to ~/.opencode/qwen/auth-config.json
|
|
18
31
|
*/
|
|
19
32
|
export function getConfigPath() {
|
|
20
33
|
return join(getConfigDir(), "auth-config.json");
|
|
21
34
|
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Load plugin configuration from ~/.opencode/qwen/auth-config.json
|
|
24
38
|
* Returns default config if file doesn't exist
|
|
39
|
+
* @returns {{ qwenMode: boolean }} Configuration object with qwenMode flag
|
|
25
40
|
*/
|
|
26
41
|
export function loadPluginConfig() {
|
|
27
42
|
const configPath = getConfigPath();
|
|
43
|
+
// Return default config if config file doesn't exist
|
|
28
44
|
if (!existsSync(configPath)) {
|
|
29
45
|
return { qwenMode: true }; // Default: QWEN_MODE enabled
|
|
30
46
|
}
|
|
@@ -33,15 +49,20 @@ export function loadPluginConfig() {
|
|
|
33
49
|
return JSON.parse(content);
|
|
34
50
|
}
|
|
35
51
|
catch (error) {
|
|
52
|
+
// Log warning and return default config on parse error
|
|
36
53
|
console.warn(`[qwen-oauth-plugin] Failed to load config from ${configPath}:`, error);
|
|
37
54
|
return { qwenMode: true };
|
|
38
55
|
}
|
|
39
56
|
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* Get QWEN_MODE setting
|
|
42
60
|
* Priority: QWEN_MODE env var > config file > default (true)
|
|
61
|
+
* @param {{ qwenMode?: boolean|string|null }} config - Configuration object from file
|
|
62
|
+
* @returns {boolean} True if QWEN_MODE is enabled, false otherwise
|
|
43
63
|
*/
|
|
44
64
|
export function getQwenMode(config) {
|
|
65
|
+
// Environment variable takes highest priority
|
|
45
66
|
const envValue = process.env.QWEN_MODE;
|
|
46
67
|
if (envValue !== undefined) {
|
|
47
68
|
return envValue === "1" || envValue.toLowerCase() === "true";
|
|
@@ -49,31 +70,44 @@ export function getQwenMode(config) {
|
|
|
49
70
|
// Ensure boolean type, avoid string "false" being truthy
|
|
50
71
|
const val = config.qwenMode;
|
|
51
72
|
if (val === undefined || val === null) return true; // default: enabled
|
|
73
|
+
// Handle string values from config file
|
|
52
74
|
if (typeof val === "string") {
|
|
53
75
|
return val === "1" || val.toLowerCase() === "true";
|
|
54
76
|
}
|
|
77
|
+
// Convert to boolean for actual boolean values
|
|
55
78
|
return !!val;
|
|
56
79
|
}
|
|
80
|
+
|
|
57
81
|
/**
|
|
58
82
|
* Get token storage path
|
|
83
|
+
* Token file contains OAuth credentials: access_token, refresh_token, expiry_date, resource_url
|
|
84
|
+
* @returns {string} Path to ~/.qwen/oauth_creds.json
|
|
59
85
|
*/
|
|
60
86
|
export function getTokenPath() {
|
|
61
87
|
return join(getQwenDir(), "oauth_creds.json");
|
|
62
88
|
}
|
|
89
|
+
|
|
63
90
|
/**
|
|
64
91
|
* Get token lock path for multi-process refresh coordination
|
|
92
|
+
* Prevents concurrent token refresh operations across multiple processes
|
|
93
|
+
* @returns {string} Path to ~/.qwen/oauth_creds.lock
|
|
65
94
|
*/
|
|
66
95
|
export function getTokenLockPath() {
|
|
67
96
|
return join(getQwenDir(), "oauth_creds.lock");
|
|
68
97
|
}
|
|
98
|
+
|
|
69
99
|
/**
|
|
70
100
|
* Get legacy token storage path used by old plugin versions
|
|
101
|
+
* Used for backward compatibility and token migration
|
|
102
|
+
* @returns {string} Path to ~/.opencode/qwen/oauth_token.json
|
|
71
103
|
*/
|
|
72
104
|
export function getLegacyTokenPath() {
|
|
73
105
|
return join(getConfigDir(), "oauth_token.json");
|
|
74
106
|
}
|
|
107
|
+
|
|
75
108
|
/**
|
|
76
109
|
* Get cache directory for prompts
|
|
110
|
+
* @returns {string} Path to ~/.opencode/cache/
|
|
77
111
|
*/
|
|
78
112
|
export function getCacheDir() {
|
|
79
113
|
return join(homedir(), ".opencode", "cache");
|