@oevortex/opencode-qwen-auth 0.1.0 → 0.1.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/dist/index.cjs +1600 -0
- package/dist/index.d.cts +450 -0
- package/dist/index.d.ts +450 -0
- package/dist/index.js +1534 -0
- package/package.json +14 -6
- package/index.ts +0 -82
- package/src/constants.ts +0 -43
- package/src/global.d.ts +0 -257
- package/src/models.ts +0 -148
- package/src/plugin/auth.ts +0 -151
- package/src/plugin/browser.ts +0 -126
- package/src/plugin/fetch-wrapper.ts +0 -460
- package/src/plugin/logger.ts +0 -111
- package/src/plugin/server.ts +0 -364
- package/src/plugin/token.ts +0 -225
- package/src/plugin.ts +0 -444
- package/src/qwen/oauth.ts +0 -271
- package/src/qwen/thinking-parser.ts +0 -190
- package/src/types.ts +0 -292
package/src/plugin/server.ts
DELETED
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OAuth callback server for local authentication
|
|
3
|
-
* Handles the OAuth redirect callback when authenticating with Qwen
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
7
|
-
import { QWEN_CALLBACK_PORT } from "../constants";
|
|
8
|
-
import { createLogger } from "./logger";
|
|
9
|
-
|
|
10
|
-
const log = createLogger("server");
|
|
11
|
-
|
|
12
|
-
/** Default timeout for waiting for OAuth callback (5 minutes) */
|
|
13
|
-
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
14
|
-
|
|
15
|
-
/** HTML response for successful authentication */
|
|
16
|
-
const SUCCESS_HTML = `
|
|
17
|
-
<!DOCTYPE html>
|
|
18
|
-
<html>
|
|
19
|
-
<head>
|
|
20
|
-
<title>Authentication Complete</title>
|
|
21
|
-
<style>
|
|
22
|
-
body {
|
|
23
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
24
|
-
display: flex;
|
|
25
|
-
justify-content: center;
|
|
26
|
-
align-items: center;
|
|
27
|
-
height: 100vh;
|
|
28
|
-
margin: 0;
|
|
29
|
-
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
30
|
-
}
|
|
31
|
-
.container {
|
|
32
|
-
text-align: center;
|
|
33
|
-
padding: 40px;
|
|
34
|
-
background: white;
|
|
35
|
-
border-radius: 16px;
|
|
36
|
-
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
37
|
-
max-width: 400px;
|
|
38
|
-
}
|
|
39
|
-
.checkmark {
|
|
40
|
-
width: 80px;
|
|
41
|
-
height: 80px;
|
|
42
|
-
background: #4CAF50;
|
|
43
|
-
border-radius: 50%;
|
|
44
|
-
display: flex;
|
|
45
|
-
justify-content: center;
|
|
46
|
-
align-items: center;
|
|
47
|
-
margin: 0 auto 20px;
|
|
48
|
-
}
|
|
49
|
-
.checkmark::after {
|
|
50
|
-
content: '✓';
|
|
51
|
-
font-size: 40px;
|
|
52
|
-
color: white;
|
|
53
|
-
}
|
|
54
|
-
h1 {
|
|
55
|
-
color: #333;
|
|
56
|
-
margin-bottom: 10px;
|
|
57
|
-
}
|
|
58
|
-
p {
|
|
59
|
-
color: #666;
|
|
60
|
-
margin: 0;
|
|
61
|
-
}
|
|
62
|
-
.close-note {
|
|
63
|
-
margin-top: 20px;
|
|
64
|
-
font-size: 14px;
|
|
65
|
-
color: #999;
|
|
66
|
-
}
|
|
67
|
-
</style>
|
|
68
|
-
</head>
|
|
69
|
-
<body>
|
|
70
|
-
<div class="container">
|
|
71
|
-
<div class="checkmark"></div>
|
|
72
|
-
<h1>Authentication Complete</h1>
|
|
73
|
-
<p>You have successfully authenticated with Qwen.</p>
|
|
74
|
-
<p class="close-note">You can close this window and return to OpenCode.</p>
|
|
75
|
-
</div>
|
|
76
|
-
</body>
|
|
77
|
-
</html>
|
|
78
|
-
`;
|
|
79
|
-
|
|
80
|
-
/** HTML response for authentication error */
|
|
81
|
-
const ERROR_HTML = (message: string) => `
|
|
82
|
-
<!DOCTYPE html>
|
|
83
|
-
<html>
|
|
84
|
-
<head>
|
|
85
|
-
<title>Authentication Failed</title>
|
|
86
|
-
<style>
|
|
87
|
-
body {
|
|
88
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
89
|
-
display: flex;
|
|
90
|
-
justify-content: center;
|
|
91
|
-
align-items: center;
|
|
92
|
-
height: 100vh;
|
|
93
|
-
margin: 0;
|
|
94
|
-
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
|
|
95
|
-
}
|
|
96
|
-
.container {
|
|
97
|
-
text-align: center;
|
|
98
|
-
padding: 40px;
|
|
99
|
-
background: white;
|
|
100
|
-
border-radius: 16px;
|
|
101
|
-
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
|
102
|
-
max-width: 400px;
|
|
103
|
-
}
|
|
104
|
-
.error-icon {
|
|
105
|
-
width: 80px;
|
|
106
|
-
height: 80px;
|
|
107
|
-
background: #f44336;
|
|
108
|
-
border-radius: 50%;
|
|
109
|
-
display: flex;
|
|
110
|
-
justify-content: center;
|
|
111
|
-
align-items: center;
|
|
112
|
-
margin: 0 auto 20px;
|
|
113
|
-
}
|
|
114
|
-
.error-icon::after {
|
|
115
|
-
content: '✕';
|
|
116
|
-
font-size: 40px;
|
|
117
|
-
color: white;
|
|
118
|
-
}
|
|
119
|
-
h1 {
|
|
120
|
-
color: #333;
|
|
121
|
-
margin-bottom: 10px;
|
|
122
|
-
}
|
|
123
|
-
p {
|
|
124
|
-
color: #666;
|
|
125
|
-
margin: 0;
|
|
126
|
-
}
|
|
127
|
-
.error-message {
|
|
128
|
-
background: #ffebee;
|
|
129
|
-
padding: 10px;
|
|
130
|
-
border-radius: 8px;
|
|
131
|
-
margin-top: 15px;
|
|
132
|
-
font-family: monospace;
|
|
133
|
-
font-size: 13px;
|
|
134
|
-
color: #c62828;
|
|
135
|
-
}
|
|
136
|
-
</style>
|
|
137
|
-
</head>
|
|
138
|
-
<body>
|
|
139
|
-
<div class="container">
|
|
140
|
-
<div class="error-icon"></div>
|
|
141
|
-
<h1>Authentication Failed</h1>
|
|
142
|
-
<p>There was a problem authenticating with Qwen.</p>
|
|
143
|
-
<div class="error-message">${escapeHtml(message)}</div>
|
|
144
|
-
</div>
|
|
145
|
-
</body>
|
|
146
|
-
</html>
|
|
147
|
-
`;
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Escape HTML special characters to prevent XSS.
|
|
151
|
-
*/
|
|
152
|
-
function escapeHtml(text: string): string {
|
|
153
|
-
return text
|
|
154
|
-
.replace(/&/g, "&")
|
|
155
|
-
.replace(/</g, "<")
|
|
156
|
-
.replace(/>/g, ">")
|
|
157
|
-
.replace(/"/g, """)
|
|
158
|
-
.replace(/'/g, "'");
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* OAuth callback listener interface.
|
|
163
|
-
*/
|
|
164
|
-
export interface OAuthListener {
|
|
165
|
-
/** The port the server is listening on */
|
|
166
|
-
port: number;
|
|
167
|
-
/** Wait for the OAuth callback and return the callback URL */
|
|
168
|
-
waitForCallback: (timeoutMs?: number) => Promise<URL>;
|
|
169
|
-
/** Close the server */
|
|
170
|
-
close: () => Promise<void>;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Start an OAuth callback listener server.
|
|
175
|
-
*
|
|
176
|
-
* This creates a local HTTP server that listens for the OAuth redirect
|
|
177
|
-
* callback and extracts the authorization code and state.
|
|
178
|
-
*
|
|
179
|
-
* @param port - Port to listen on (defaults to QWEN_CALLBACK_PORT)
|
|
180
|
-
* @returns OAuthListener interface for waiting and cleanup
|
|
181
|
-
*/
|
|
182
|
-
export async function startOAuthListener(port: number = QWEN_CALLBACK_PORT): Promise<OAuthListener> {
|
|
183
|
-
return new Promise((resolve, reject) => {
|
|
184
|
-
let callbackResolve: ((url: URL) => void) | null = null;
|
|
185
|
-
let callbackReject: ((error: Error) => void) | null = null;
|
|
186
|
-
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
187
|
-
|
|
188
|
-
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
189
|
-
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
190
|
-
|
|
191
|
-
log.debug("Received request", { path: url.pathname, search: url.search });
|
|
192
|
-
|
|
193
|
-
// Only handle the callback path
|
|
194
|
-
if (url.pathname !== "/oauth-callback") {
|
|
195
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
196
|
-
res.end("Not Found");
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Check for error in callback
|
|
201
|
-
const error = url.searchParams.get("error");
|
|
202
|
-
if (error) {
|
|
203
|
-
const errorDescription = url.searchParams.get("error_description") || error;
|
|
204
|
-
log.error("OAuth callback received error", { error, errorDescription });
|
|
205
|
-
|
|
206
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
207
|
-
res.end(ERROR_HTML(errorDescription));
|
|
208
|
-
|
|
209
|
-
if (callbackReject) {
|
|
210
|
-
callbackReject(new Error(`OAuth error: ${errorDescription}`));
|
|
211
|
-
callbackReject = null;
|
|
212
|
-
callbackResolve = null;
|
|
213
|
-
}
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Extract code and state
|
|
218
|
-
const code = url.searchParams.get("code");
|
|
219
|
-
const state = url.searchParams.get("state");
|
|
220
|
-
|
|
221
|
-
if (!code) {
|
|
222
|
-
const message = "Missing authorization code in callback";
|
|
223
|
-
log.error(message);
|
|
224
|
-
|
|
225
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
226
|
-
res.end(ERROR_HTML(message));
|
|
227
|
-
|
|
228
|
-
if (callbackReject) {
|
|
229
|
-
callbackReject(new Error(message));
|
|
230
|
-
callbackReject = null;
|
|
231
|
-
callbackResolve = null;
|
|
232
|
-
}
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
log.info("OAuth callback received successfully", { hasCode: true, hasState: !!state });
|
|
237
|
-
|
|
238
|
-
// Send success response
|
|
239
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
240
|
-
res.end(SUCCESS_HTML);
|
|
241
|
-
|
|
242
|
-
// Resolve the callback promise
|
|
243
|
-
if (callbackResolve) {
|
|
244
|
-
callbackResolve(url);
|
|
245
|
-
callbackResolve = null;
|
|
246
|
-
callbackReject = null;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Clear timeout
|
|
250
|
-
if (timeoutId) {
|
|
251
|
-
clearTimeout(timeoutId);
|
|
252
|
-
timeoutId = null;
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// Handle server errors
|
|
257
|
-
server.on("error", (err: Error) => {
|
|
258
|
-
log.error("Server error", { error: err.message });
|
|
259
|
-
|
|
260
|
-
if ((err as NodeJS.ErrnoException).code === "EADDRINUSE") {
|
|
261
|
-
reject(new Error(`Port ${port} is already in use. Cannot start OAuth callback server.`));
|
|
262
|
-
} else {
|
|
263
|
-
reject(err);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
// Start listening
|
|
268
|
-
server.listen(port, "127.0.0.1", () => {
|
|
269
|
-
log.info("OAuth callback server started", { port });
|
|
270
|
-
|
|
271
|
-
const listener: OAuthListener = {
|
|
272
|
-
port,
|
|
273
|
-
|
|
274
|
-
waitForCallback: (timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<URL> => {
|
|
275
|
-
return new Promise((resolveCallback, rejectCallback) => {
|
|
276
|
-
callbackResolve = resolveCallback;
|
|
277
|
-
callbackReject = rejectCallback;
|
|
278
|
-
|
|
279
|
-
// Set timeout
|
|
280
|
-
timeoutId = setTimeout(() => {
|
|
281
|
-
if (callbackReject) {
|
|
282
|
-
callbackReject(new Error(`OAuth callback timeout after ${timeoutMs / 1000} seconds`));
|
|
283
|
-
callbackReject = null;
|
|
284
|
-
callbackResolve = null;
|
|
285
|
-
}
|
|
286
|
-
}, timeoutMs);
|
|
287
|
-
});
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
close: async (): Promise<void> => {
|
|
291
|
-
return new Promise((resolveClose, rejectClose) => {
|
|
292
|
-
// Clear any pending timeout
|
|
293
|
-
if (timeoutId) {
|
|
294
|
-
clearTimeout(timeoutId);
|
|
295
|
-
timeoutId = null;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Reject any pending callback
|
|
299
|
-
if (callbackReject) {
|
|
300
|
-
callbackReject(new Error("OAuth listener closed"));
|
|
301
|
-
callbackReject = null;
|
|
302
|
-
callbackResolve = null;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// Close the server
|
|
306
|
-
server.close((err) => {
|
|
307
|
-
if (err) {
|
|
308
|
-
log.warn("Error closing server", { error: err.message });
|
|
309
|
-
rejectClose(err);
|
|
310
|
-
} else {
|
|
311
|
-
log.debug("OAuth callback server closed");
|
|
312
|
-
resolveClose();
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
},
|
|
317
|
-
};
|
|
318
|
-
|
|
319
|
-
resolve(listener);
|
|
320
|
-
});
|
|
321
|
-
});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Check if a port is available for listening.
|
|
326
|
-
*
|
|
327
|
-
* @param port - Port number to check
|
|
328
|
-
* @returns True if port is available
|
|
329
|
-
*/
|
|
330
|
-
export async function isPortAvailable(port: number): Promise<boolean> {
|
|
331
|
-
return new Promise((resolve) => {
|
|
332
|
-
const server = createServer();
|
|
333
|
-
|
|
334
|
-
server.on("error", () => {
|
|
335
|
-
resolve(false);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
server.listen(port, "127.0.0.1", () => {
|
|
339
|
-
server.close(() => {
|
|
340
|
-
resolve(true);
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Find an available port starting from the given port.
|
|
348
|
-
*
|
|
349
|
-
* @param startPort - Port to start searching from
|
|
350
|
-
* @param maxAttempts - Maximum number of ports to try
|
|
351
|
-
* @returns Available port number
|
|
352
|
-
*/
|
|
353
|
-
export async function findAvailablePort(
|
|
354
|
-
startPort: number = QWEN_CALLBACK_PORT,
|
|
355
|
-
maxAttempts: number = 10
|
|
356
|
-
): Promise<number> {
|
|
357
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
358
|
-
const port = startPort + i;
|
|
359
|
-
if (await isPortAvailable(port)) {
|
|
360
|
-
return port;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
throw new Error(`Could not find available port (tried ${startPort} - ${startPort + maxAttempts - 1})`);
|
|
364
|
-
}
|
package/src/plugin/token.ts
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token refresh utilities for the Qwen OpenCode plugin
|
|
3
|
-
* Handles refreshing OAuth access tokens when they expire
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
QWEN_OAUTH_TOKEN_ENDPOINT,
|
|
8
|
-
QWEN_OAUTH_CLIENT_ID,
|
|
9
|
-
HTTP_OK,
|
|
10
|
-
TOKEN_REFRESH_BUFFER_MS,
|
|
11
|
-
} from "../constants";
|
|
12
|
-
|
|
13
|
-
import type { OAuthAuthDetails, QwenTokenResponse, PluginContext } from "../types";
|
|
14
|
-
import { parseRefreshParts, formatRefreshParts } from "./auth";
|
|
15
|
-
import { createLogger } from "./logger";
|
|
16
|
-
|
|
17
|
-
const log = createLogger("token");
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Encode object as URL-encoded form data.
|
|
21
|
-
*/
|
|
22
|
-
function encodeFormData(data: Record<string, string>): string {
|
|
23
|
-
return Object.entries(data)
|
|
24
|
-
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
|
25
|
-
.join("&");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Refresh an OAuth access token using the refresh token.
|
|
30
|
-
*
|
|
31
|
-
* @param auth - Current OAuth auth details with refresh token
|
|
32
|
-
* @param client - Plugin client for showing toasts
|
|
33
|
-
* @returns Updated auth details with new access token, or null on failure
|
|
34
|
-
*/
|
|
35
|
-
export async function refreshAccessToken(
|
|
36
|
-
auth: OAuthAuthDetails,
|
|
37
|
-
client: PluginContext["client"]
|
|
38
|
-
): Promise<OAuthAuthDetails | null> {
|
|
39
|
-
const parts = parseRefreshParts(auth.refresh);
|
|
40
|
-
|
|
41
|
-
if (!parts.refreshToken) {
|
|
42
|
-
log.error("No refresh token available for token refresh");
|
|
43
|
-
await client.tui.showToast({
|
|
44
|
-
body: {
|
|
45
|
-
message: "No refresh token available. Please re-authenticate.",
|
|
46
|
-
variant: "error",
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
log.debug("Refreshing access token...");
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
const bodyData = {
|
|
56
|
-
grant_type: "refresh_token",
|
|
57
|
-
refresh_token: parts.refreshToken,
|
|
58
|
-
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
62
|
-
method: "POST",
|
|
63
|
-
headers: {
|
|
64
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
65
|
-
Accept: "application/json",
|
|
66
|
-
"User-Agent":
|
|
67
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
68
|
-
},
|
|
69
|
-
body: encodeFormData(bodyData),
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
if (response.status !== HTTP_OK) {
|
|
73
|
-
const errorText = await response.text();
|
|
74
|
-
log.error("Token refresh failed", {
|
|
75
|
-
status: response.status,
|
|
76
|
-
statusText: response.statusText,
|
|
77
|
-
error: errorText.slice(0, 200),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
await client.tui.showToast({
|
|
81
|
-
body: {
|
|
82
|
-
message: `Token refresh failed: ${response.status} ${response.statusText}`,
|
|
83
|
-
variant: "error",
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
let tokenData: QwenTokenResponse;
|
|
90
|
-
try {
|
|
91
|
-
tokenData = (await response.json()) as QwenTokenResponse;
|
|
92
|
-
} catch (parseError) {
|
|
93
|
-
const text = await response.text();
|
|
94
|
-
log.error("Failed to parse token response", {
|
|
95
|
-
error: parseError instanceof Error ? parseError.message : String(parseError),
|
|
96
|
-
responsePreview: text.slice(0, 200),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
await client.tui.showToast({
|
|
100
|
-
body: {
|
|
101
|
-
message: "Failed to parse token refresh response",
|
|
102
|
-
variant: "error",
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (tokenData.error) {
|
|
109
|
-
log.error("Token refresh returned error", {
|
|
110
|
-
error: tokenData.error,
|
|
111
|
-
description: tokenData.error_description,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
await client.tui.showToast({
|
|
115
|
-
body: {
|
|
116
|
-
message: `Token refresh error: ${tokenData.error}`,
|
|
117
|
-
variant: "error",
|
|
118
|
-
},
|
|
119
|
-
});
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Calculate new expiry time
|
|
124
|
-
const expiresAt = Date.now() + tokenData.expires_in * 1000;
|
|
125
|
-
|
|
126
|
-
// Use new refresh token if provided, otherwise keep the old one
|
|
127
|
-
const newRefreshToken = tokenData.refresh_token || parts.refreshToken;
|
|
128
|
-
|
|
129
|
-
log.info("Token refreshed successfully", {
|
|
130
|
-
expiresIn: tokenData.expires_in,
|
|
131
|
-
hasNewRefreshToken: !!tokenData.refresh_token,
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Build updated auth details
|
|
135
|
-
const updatedParts = {
|
|
136
|
-
refreshToken: newRefreshToken,
|
|
137
|
-
resourceUrl: parts.resourceUrl,
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
type: "oauth",
|
|
142
|
-
access: tokenData.access_token,
|
|
143
|
-
refresh: formatRefreshParts(updatedParts),
|
|
144
|
-
expires: expiresAt,
|
|
145
|
-
};
|
|
146
|
-
} catch (error) {
|
|
147
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
148
|
-
log.error("Token refresh failed with exception", { error: errorMessage });
|
|
149
|
-
|
|
150
|
-
await client.tui.showToast({
|
|
151
|
-
body: {
|
|
152
|
-
message: `Token refresh failed: ${errorMessage}`,
|
|
153
|
-
variant: "error",
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Check if an access token needs refresh (expired or about to expire).
|
|
162
|
-
*/
|
|
163
|
-
export function needsTokenRefresh(auth: OAuthAuthDetails): boolean {
|
|
164
|
-
if (!auth.access || typeof auth.expires !== "number") {
|
|
165
|
-
return true;
|
|
166
|
-
}
|
|
167
|
-
return auth.expires <= Date.now() + TOKEN_REFRESH_BUFFER_MS;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Ensure we have a valid access token, refreshing if necessary.
|
|
172
|
-
*
|
|
173
|
-
* @param auth - Current auth details
|
|
174
|
-
* @param client - Plugin client for showing toasts
|
|
175
|
-
* @returns Updated auth details, or null if refresh failed
|
|
176
|
-
*/
|
|
177
|
-
export async function ensureValidToken(
|
|
178
|
-
auth: OAuthAuthDetails,
|
|
179
|
-
client: PluginContext["client"]
|
|
180
|
-
): Promise<OAuthAuthDetails | null> {
|
|
181
|
-
if (!needsTokenRefresh(auth)) {
|
|
182
|
-
return auth;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
log.debug("Token needs refresh, refreshing...");
|
|
186
|
-
return refreshAccessToken(auth, client);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Calculate time until token expiry in milliseconds.
|
|
191
|
-
* Returns 0 if token is already expired or invalid.
|
|
192
|
-
*/
|
|
193
|
-
export function getTimeUntilExpiry(auth: OAuthAuthDetails): number {
|
|
194
|
-
if (!auth.expires || typeof auth.expires !== "number") {
|
|
195
|
-
return 0;
|
|
196
|
-
}
|
|
197
|
-
return Math.max(0, auth.expires - Date.now());
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Format time until expiry as a human-readable string.
|
|
202
|
-
*/
|
|
203
|
-
export function formatTimeUntilExpiry(auth: OAuthAuthDetails): string {
|
|
204
|
-
const ms = getTimeUntilExpiry(auth);
|
|
205
|
-
|
|
206
|
-
if (ms <= 0) {
|
|
207
|
-
return "expired";
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const seconds = Math.floor(ms / 1000);
|
|
211
|
-
const minutes = Math.floor(seconds / 60);
|
|
212
|
-
const hours = Math.floor(minutes / 60);
|
|
213
|
-
|
|
214
|
-
if (hours > 0) {
|
|
215
|
-
const remainingMinutes = minutes % 60;
|
|
216
|
-
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (minutes > 0) {
|
|
220
|
-
const remainingSeconds = seconds % 60;
|
|
221
|
-
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return `${seconds}s`;
|
|
225
|
-
}
|