@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.
@@ -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, "&amp;")
155
- .replace(/</g, "&lt;")
156
- .replace(/>/g, "&gt;")
157
- .replace(/"/g, "&quot;")
158
- .replace(/'/g, "&#039;");
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
- }
@@ -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
- }