opencode-qwencode-auth 1.0.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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Qwen OAuth Device Flow Implementation
3
+ *
4
+ * Based on qwen-code's implementation (RFC 8628)
5
+ * Handles PKCE, device authorization, and token polling
6
+ */
7
+
8
+ import { randomBytes, createHash, randomUUID } from 'node:crypto';
9
+
10
+ import { QWEN_OAUTH_CONFIG } from '../constants.js';
11
+ import type { QwenCredentials } from '../types.js';
12
+
13
+ /**
14
+ * Device authorization response from Qwen OAuth
15
+ */
16
+ export interface DeviceAuthorizationResponse {
17
+ device_code: string;
18
+ user_code: string;
19
+ verification_uri: string;
20
+ verification_uri_complete: string;
21
+ expires_in: number;
22
+ }
23
+
24
+ /**
25
+ * Token response from Qwen OAuth
26
+ */
27
+ export interface TokenResponse {
28
+ access_token: string;
29
+ refresh_token?: string;
30
+ token_type: string;
31
+ expires_in: number;
32
+ scope?: string;
33
+ resource_url?: string;
34
+ }
35
+
36
+ /**
37
+ * Generate PKCE code verifier and challenge (RFC 7636)
38
+ */
39
+ export function generatePKCE(): { verifier: string; challenge: string } {
40
+ const verifier = randomBytes(32).toString('base64url');
41
+ const challenge = createHash('sha256')
42
+ .update(verifier)
43
+ .digest('base64url');
44
+
45
+ return { verifier, challenge };
46
+ }
47
+
48
+ /**
49
+ * Generate random state for OAuth
50
+ */
51
+ export function generateState(): string {
52
+ return randomBytes(16).toString('hex');
53
+ }
54
+
55
+ /**
56
+ * Convert object to URL-encoded form data
57
+ */
58
+ function objectToUrlEncoded(data: Record<string, string>): string {
59
+ return Object.keys(data)
60
+ .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
61
+ .join('&');
62
+ }
63
+
64
+ /**
65
+ * Request device authorization from Qwen OAuth
66
+ * Returns device_code, user_code, and verification URL
67
+ */
68
+ export async function requestDeviceAuthorization(
69
+ codeChallenge: string
70
+ ): Promise<DeviceAuthorizationResponse> {
71
+ const bodyData = {
72
+ client_id: QWEN_OAUTH_CONFIG.clientId,
73
+ scope: QWEN_OAUTH_CONFIG.scope,
74
+ code_challenge: codeChallenge,
75
+ code_challenge_method: 'S256',
76
+ };
77
+
78
+ const response = await fetch(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, {
79
+ method: 'POST',
80
+ headers: {
81
+ 'Content-Type': 'application/x-www-form-urlencoded',
82
+ Accept: 'application/json',
83
+ 'x-request-id': randomUUID(),
84
+ },
85
+ body: objectToUrlEncoded(bodyData),
86
+ });
87
+
88
+ if (!response.ok) {
89
+ const errorData = await response.text();
90
+ throw new Error(
91
+ `Device authorization failed: ${response.status} ${response.statusText}. Response: ${errorData}`
92
+ );
93
+ }
94
+
95
+ const result = await response.json() as DeviceAuthorizationResponse;
96
+
97
+ if (!result.device_code || !result.user_code) {
98
+ throw new Error('Invalid device authorization response');
99
+ }
100
+
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * Poll for device token after user authorization
106
+ * Returns null if still pending, throws on error
107
+ */
108
+ export async function pollDeviceToken(
109
+ deviceCode: string,
110
+ codeVerifier: string
111
+ ): Promise<TokenResponse | null> {
112
+ const bodyData = {
113
+ grant_type: QWEN_OAUTH_CONFIG.grantType,
114
+ client_id: QWEN_OAUTH_CONFIG.clientId,
115
+ device_code: deviceCode,
116
+ code_verifier: codeVerifier,
117
+ };
118
+
119
+ const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'Content-Type': 'application/x-www-form-urlencoded',
123
+ Accept: 'application/json',
124
+ },
125
+ body: objectToUrlEncoded(bodyData),
126
+ });
127
+
128
+ if (!response.ok) {
129
+ const responseText = await response.text();
130
+
131
+ // Try to parse error response
132
+ try {
133
+ const errorData = JSON.parse(responseText) as { error?: string; error_description?: string };
134
+
135
+ // RFC 8628: authorization_pending means user hasn't authorized yet
136
+ if (response.status === 400 && errorData.error === 'authorization_pending') {
137
+ return null; // Still pending
138
+ }
139
+
140
+ // RFC 8628: slow_down means we should increase poll interval
141
+ if (response.status === 429 && errorData.error === 'slow_down') {
142
+ return null; // Still pending, but should slow down
143
+ }
144
+
145
+ throw new Error(
146
+ `Token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || responseText}`
147
+ );
148
+ } catch (parseError) {
149
+ if (parseError instanceof SyntaxError) {
150
+ throw new Error(
151
+ `Token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`
152
+ );
153
+ }
154
+ throw parseError;
155
+ }
156
+ }
157
+
158
+ return (await response.json()) as TokenResponse;
159
+ }
160
+
161
+ /**
162
+ * Convert token response to QwenCredentials format
163
+ */
164
+ export function tokenResponseToCredentials(tokenResponse: TokenResponse): QwenCredentials {
165
+ return {
166
+ accessToken: tokenResponse.access_token,
167
+ tokenType: tokenResponse.token_type || 'Bearer',
168
+ refreshToken: tokenResponse.refresh_token,
169
+ resourceUrl: tokenResponse.resource_url,
170
+ expiryDate: Date.now() + tokenResponse.expires_in * 1000,
171
+ scope: tokenResponse.scope,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Refresh the access token using refresh_token grant
177
+ */
178
+ export async function refreshAccessToken(refreshToken: string): Promise<QwenCredentials> {
179
+ const bodyData = {
180
+ grant_type: 'refresh_token',
181
+ refresh_token: refreshToken,
182
+ client_id: QWEN_OAUTH_CONFIG.clientId,
183
+ };
184
+
185
+ const response = await fetch(QWEN_OAUTH_CONFIG.tokenEndpoint, {
186
+ method: 'POST',
187
+ headers: {
188
+ 'Content-Type': 'application/x-www-form-urlencoded',
189
+ Accept: 'application/json',
190
+ },
191
+ body: objectToUrlEncoded(bodyData),
192
+ });
193
+
194
+ if (!response.ok) {
195
+ const errorText = await response.text();
196
+ throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
197
+ }
198
+
199
+ const data = await response.json() as TokenResponse;
200
+
201
+ return {
202
+ accessToken: data.access_token,
203
+ tokenType: data.token_type || 'Bearer',
204
+ refreshToken: data.refresh_token || refreshToken,
205
+ resourceUrl: data.resource_url,
206
+ expiryDate: Date.now() + data.expires_in * 1000,
207
+ scope: data.scope,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Check if credentials are expired
213
+ * Uses 30 second buffer like qwen-code
214
+ */
215
+ export function isCredentialsExpired(credentials: QwenCredentials): boolean {
216
+ if (!credentials.expiryDate) {
217
+ return false; // Assume not expired if no expiry time
218
+ }
219
+
220
+ // Add 30 second buffer (same as qwen-code)
221
+ return Date.now() > credentials.expiryDate - 30 * 1000;
222
+ }
223
+
224
+ /**
225
+ * Perform full device authorization flow
226
+ * Opens browser for user to authorize, polls for token
227
+ */
228
+ export async function performDeviceAuthFlow(
229
+ onVerificationUrl: (url: string, userCode: string) => void,
230
+ pollIntervalMs = 2000,
231
+ timeoutMs = 5 * 60 * 1000
232
+ ): Promise<QwenCredentials> {
233
+ // Generate PKCE
234
+ const { verifier, challenge } = generatePKCE();
235
+
236
+ // Request device authorization
237
+ const deviceAuth = await requestDeviceAuthorization(challenge);
238
+
239
+ // Notify caller of verification URL
240
+ onVerificationUrl(deviceAuth.verification_uri_complete, deviceAuth.user_code);
241
+
242
+ // Poll for token
243
+ const startTime = Date.now();
244
+ let interval = pollIntervalMs;
245
+
246
+ while (Date.now() - startTime < timeoutMs) {
247
+ await new Promise((resolve) => setTimeout(resolve, interval));
248
+
249
+ try {
250
+ const tokenResponse = await pollDeviceToken(deviceAuth.device_code, verifier);
251
+
252
+ if (tokenResponse) {
253
+ return tokenResponseToCredentials(tokenResponse);
254
+ }
255
+ } catch (error) {
256
+ // Check if we should slow down
257
+ if (error instanceof Error && error.message.includes('slow_down')) {
258
+ interval = Math.min(interval * 1.5, 10000); // Increase interval, max 10s
259
+ } else {
260
+ throw error;
261
+ }
262
+ }
263
+ }
264
+
265
+ throw new Error('Device authorization timeout');
266
+ }
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Type Definitions for Qwen Auth Plugin
3
+ */
4
+
5
+ import type { QWEN_MODELS } from './constants.js';
6
+
7
+ // ============================================
8
+ // Credentials Types
9
+ // ============================================
10
+
11
+ export interface QwenCredentials {
12
+ accessToken: string;
13
+ tokenType?: string; // "Bearer"
14
+ refreshToken?: string;
15
+ resourceUrl?: string; // "portal.qwen.ai" - base URL da API
16
+ expiryDate?: number; // timestamp em ms (formato qwen-code)
17
+ scope?: string; // "openid profile email"
18
+ }
19
+
20
+ export interface QwenOAuthState {
21
+ codeVerifier: string;
22
+ state: string;
23
+ }
24
+
25
+ // ============================================
26
+ // API Types
27
+ // ============================================
28
+
29
+ export type QwenModelId = keyof typeof QWEN_MODELS;
30
+
31
+ export interface ChatMessage {
32
+ role: 'system' | 'user' | 'assistant' | 'tool';
33
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>;
34
+ name?: string;
35
+ tool_calls?: Array<{
36
+ id: string;
37
+ type: 'function';
38
+ function: { name: string; arguments: string };
39
+ }>;
40
+ tool_call_id?: string;
41
+ }
42
+
43
+ export interface ChatCompletionRequest {
44
+ model: string;
45
+ messages: ChatMessage[];
46
+ temperature?: number;
47
+ top_p?: number;
48
+ max_tokens?: number;
49
+ stream?: boolean;
50
+ tools?: Array<{
51
+ type: 'function';
52
+ function: {
53
+ name: string;
54
+ description: string;
55
+ parameters: Record<string, unknown>;
56
+ };
57
+ }>;
58
+ tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } };
59
+ }
60
+
61
+ export interface ChatCompletionResponse {
62
+ id: string;
63
+ object: 'chat.completion';
64
+ created: number;
65
+ model: string;
66
+ choices: Array<{
67
+ index: number;
68
+ message: {
69
+ role: 'assistant';
70
+ content: string | null;
71
+ tool_calls?: Array<{
72
+ id: string;
73
+ type: 'function';
74
+ function: { name: string; arguments: string };
75
+ }>;
76
+ };
77
+ finish_reason: 'stop' | 'length' | 'tool_calls' | null;
78
+ }>;
79
+ usage?: {
80
+ prompt_tokens: number;
81
+ completion_tokens: number;
82
+ total_tokens: number;
83
+ };
84
+ }
85
+
86
+ export interface StreamChunk {
87
+ id: string;
88
+ object: 'chat.completion.chunk';
89
+ created: number;
90
+ model: string;
91
+ choices: Array<{
92
+ index: number;
93
+ delta: {
94
+ role?: 'assistant';
95
+ content?: string;
96
+ tool_calls?: Array<{
97
+ index: number;
98
+ id?: string;
99
+ type?: 'function';
100
+ function?: { name?: string; arguments?: string };
101
+ }>;
102
+ };
103
+ finish_reason: 'stop' | 'length' | 'tool_calls' | null;
104
+ }>;
105
+ }