mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
910 lines
29 KiB
TypeScript
910 lines
29 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import crypto from 'crypto';
|
|
import path from 'node:path';
|
|
import { promises as fs } from 'node:fs';
|
|
import * as os from 'os';
|
|
|
|
import open from 'open';
|
|
import { EventEmitter } from 'events';
|
|
import type { Config } from '../config/config.js';
|
|
import { randomUUID } from 'node:crypto';
|
|
import {
|
|
SharedTokenManager,
|
|
TokenManagerError,
|
|
TokenError,
|
|
} from './sharedTokenManager.js';
|
|
|
|
// OAuth Endpoints
|
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
|
|
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
|
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
|
|
|
// OAuth Client Configuration
|
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
|
|
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
|
|
// File System Configuration
|
|
const QWEN_DIR = '.qwen';
|
|
const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json';
|
|
|
|
/**
|
|
* PKCE (Proof Key for Code Exchange) utilities
|
|
* Implements RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients
|
|
*/
|
|
|
|
/**
|
|
* Generate a random code verifier for PKCE
|
|
* @returns A random string of 43-128 characters
|
|
*/
|
|
export function generateCodeVerifier(): string {
|
|
return crypto.randomBytes(32).toString('base64url');
|
|
}
|
|
|
|
/**
|
|
* Generate a code challenge from a code verifier using SHA-256
|
|
* @param codeVerifier The code verifier string
|
|
* @returns The code challenge string
|
|
*/
|
|
export function generateCodeChallenge(codeVerifier: string): string {
|
|
const hash = crypto.createHash('sha256');
|
|
hash.update(codeVerifier);
|
|
return hash.digest('base64url');
|
|
}
|
|
|
|
/**
|
|
* Generate PKCE code verifier and challenge pair
|
|
* @returns Object containing code_verifier and code_challenge
|
|
*/
|
|
export function generatePKCEPair(): {
|
|
code_verifier: string;
|
|
code_challenge: string;
|
|
} {
|
|
const codeVerifier = generateCodeVerifier();
|
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
return { code_verifier: codeVerifier, code_challenge: codeChallenge };
|
|
}
|
|
|
|
/**
|
|
* Convert object to URL-encoded form data
|
|
* @param data The object to convert
|
|
* @returns URL-encoded string
|
|
*/
|
|
function objectToUrlEncoded(data: Record<string, string>): string {
|
|
return Object.keys(data)
|
|
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
|
.join('&');
|
|
}
|
|
|
|
/**
|
|
* Standard error response data
|
|
*/
|
|
export interface ErrorData {
|
|
error: string;
|
|
error_description: string;
|
|
}
|
|
|
|
/**
|
|
* Custom error class to indicate that credentials should be cleared
|
|
* This is thrown when a 400 error occurs during token refresh, indicating
|
|
* that the refresh token is expired or invalid
|
|
*/
|
|
export class CredentialsClearRequiredError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public originalError?: unknown,
|
|
) {
|
|
super(message);
|
|
this.name = 'CredentialsClearRequiredError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Qwen OAuth2 credentials interface
|
|
*/
|
|
export interface QwenCredentials {
|
|
access_token?: string;
|
|
refresh_token?: string;
|
|
id_token?: string;
|
|
expiry_date?: number;
|
|
token_type?: string;
|
|
resource_url?: string;
|
|
}
|
|
|
|
/**
|
|
* Device authorization success data
|
|
*/
|
|
export interface DeviceAuthorizationData {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
verification_uri_complete: string;
|
|
expires_in: number;
|
|
}
|
|
|
|
/**
|
|
* Device authorization response interface
|
|
*/
|
|
export type DeviceAuthorizationResponse = DeviceAuthorizationData | ErrorData;
|
|
|
|
/**
|
|
* Type guard to check if device authorization was successful
|
|
*/
|
|
export function isDeviceAuthorizationSuccess(
|
|
response: DeviceAuthorizationResponse,
|
|
): response is DeviceAuthorizationData {
|
|
return 'device_code' in response;
|
|
}
|
|
|
|
/**
|
|
* Device token success data
|
|
*/
|
|
export interface DeviceTokenData {
|
|
access_token: string | null;
|
|
refresh_token?: string | null;
|
|
token_type: string;
|
|
expires_in: number | null;
|
|
scope?: string | null;
|
|
endpoint?: string;
|
|
resource_url?: string;
|
|
}
|
|
|
|
/**
|
|
* Device token pending response
|
|
*/
|
|
export interface DeviceTokenPendingData {
|
|
status: 'pending';
|
|
slowDown?: boolean; // Indicates if client should increase polling interval
|
|
}
|
|
|
|
/**
|
|
* Device token response interface
|
|
*/
|
|
export type DeviceTokenResponse =
|
|
| DeviceTokenData
|
|
| DeviceTokenPendingData
|
|
| ErrorData;
|
|
|
|
/**
|
|
* Type guard to check if device token response was successful
|
|
*/
|
|
export function isDeviceTokenSuccess(
|
|
response: DeviceTokenResponse,
|
|
): response is DeviceTokenData {
|
|
return (
|
|
'access_token' in response &&
|
|
response.access_token !== null &&
|
|
response.access_token !== undefined &&
|
|
typeof response.access_token === 'string' &&
|
|
response.access_token.length > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if device token response is pending
|
|
*/
|
|
export function isDeviceTokenPending(
|
|
response: DeviceTokenResponse,
|
|
): response is DeviceTokenPendingData {
|
|
return (
|
|
'status' in response &&
|
|
(response as DeviceTokenPendingData).status === 'pending'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Type guard to check if response is an error
|
|
*/
|
|
export function isErrorResponse(
|
|
response:
|
|
| DeviceAuthorizationResponse
|
|
| DeviceTokenResponse
|
|
| TokenRefreshResponse,
|
|
): response is ErrorData {
|
|
return 'error' in response;
|
|
}
|
|
|
|
/**
|
|
* Token refresh success data
|
|
*/
|
|
export interface TokenRefreshData {
|
|
access_token: string;
|
|
token_type: string;
|
|
expires_in: number;
|
|
refresh_token?: string; // Some OAuth servers may return a new refresh token
|
|
resource_url?: string;
|
|
}
|
|
|
|
/**
|
|
* Token refresh response interface
|
|
*/
|
|
export type TokenRefreshResponse = TokenRefreshData | ErrorData;
|
|
|
|
/**
|
|
* Qwen OAuth2 client interface
|
|
*/
|
|
export interface IQwenOAuth2Client {
|
|
setCredentials(credentials: QwenCredentials): void;
|
|
getCredentials(): QwenCredentials;
|
|
getAccessToken(): Promise<{ token?: string }>;
|
|
requestDeviceAuthorization(options: {
|
|
scope: string;
|
|
code_challenge: string;
|
|
code_challenge_method: string;
|
|
}): Promise<DeviceAuthorizationResponse>;
|
|
pollDeviceToken(options: {
|
|
device_code: string;
|
|
code_verifier: string;
|
|
}): Promise<DeviceTokenResponse>;
|
|
refreshAccessToken(): Promise<TokenRefreshResponse>;
|
|
}
|
|
|
|
/**
|
|
* Qwen OAuth2 client implementation
|
|
*/
|
|
export class QwenOAuth2Client implements IQwenOAuth2Client {
|
|
private credentials: QwenCredentials = {};
|
|
private sharedManager: SharedTokenManager;
|
|
|
|
constructor() {
|
|
this.sharedManager = SharedTokenManager.getInstance();
|
|
}
|
|
|
|
setCredentials(credentials: QwenCredentials): void {
|
|
this.credentials = credentials;
|
|
}
|
|
|
|
getCredentials(): QwenCredentials {
|
|
return this.credentials;
|
|
}
|
|
|
|
async getAccessToken(): Promise<{ token?: string }> {
|
|
try {
|
|
// Always use shared manager for consistency - this prevents race conditions
|
|
// between local credential state and shared state
|
|
const credentials = await this.sharedManager.getValidCredentials(this);
|
|
return { token: credentials.access_token };
|
|
} catch (error) {
|
|
console.warn('Failed to get access token from shared manager:', error);
|
|
|
|
// Don't use fallback to local credentials to prevent race conditions
|
|
// All token management should go through SharedTokenManager for consistency
|
|
// This ensures single source of truth and prevents cross-session issues
|
|
return { token: undefined };
|
|
}
|
|
}
|
|
|
|
async requestDeviceAuthorization(options: {
|
|
scope: string;
|
|
code_challenge: string;
|
|
code_challenge_method: string;
|
|
}): Promise<DeviceAuthorizationResponse> {
|
|
const bodyData = {
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
scope: options.scope,
|
|
code_challenge: options.code_challenge,
|
|
code_challenge_method: options.code_challenge_method,
|
|
};
|
|
|
|
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Accept: 'application/json',
|
|
'x-request-id': randomUUID(),
|
|
},
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.text();
|
|
throw new Error(
|
|
`Device authorization failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
|
|
);
|
|
}
|
|
|
|
const result = (await response.json()) as DeviceAuthorizationResponse;
|
|
console.debug('Device authorization result:', result);
|
|
|
|
// Check if the response indicates success
|
|
if (!isDeviceAuthorizationSuccess(result)) {
|
|
const errorData = result as ErrorData;
|
|
throw new Error(
|
|
`Device authorization failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async pollDeviceToken(options: {
|
|
device_code: string;
|
|
code_verifier: string;
|
|
}): Promise<DeviceTokenResponse> {
|
|
const bodyData = {
|
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
device_code: options.device_code,
|
|
code_verifier: options.code_verifier,
|
|
};
|
|
|
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Accept: 'application/json',
|
|
},
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Read response body as text first (can only be read once)
|
|
const responseText = await response.text();
|
|
|
|
// Try to parse as JSON to check for OAuth RFC 8628 standard errors
|
|
let errorData: ErrorData | null = null;
|
|
try {
|
|
errorData = JSON.parse(responseText) as ErrorData;
|
|
} catch (_parseError) {
|
|
// If JSON parsing fails, use text response
|
|
const error = new Error(
|
|
`Device token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`,
|
|
);
|
|
(error as Error & { status?: number }).status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
// According to OAuth RFC 8628, handle standard polling responses
|
|
if (
|
|
response.status === 400 &&
|
|
errorData.error === 'authorization_pending'
|
|
) {
|
|
// User has not yet approved the authorization request. Continue polling.
|
|
return { status: 'pending' } as DeviceTokenPendingData;
|
|
}
|
|
|
|
if (response.status === 429 && errorData.error === 'slow_down') {
|
|
// Client is polling too frequently. Return pending with slowDown flag.
|
|
return {
|
|
status: 'pending',
|
|
slowDown: true,
|
|
} as DeviceTokenPendingData;
|
|
}
|
|
|
|
// Handle other 400 errors (access_denied, expired_token, etc.) as real errors
|
|
|
|
// For other errors, throw with proper error information
|
|
const error = new Error(
|
|
`Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`,
|
|
);
|
|
(error as Error & { status?: number }).status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
return (await response.json()) as DeviceTokenResponse;
|
|
}
|
|
|
|
async refreshAccessToken(): Promise<TokenRefreshResponse> {
|
|
if (!this.credentials.refresh_token) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
const bodyData = {
|
|
grant_type: 'refresh_token',
|
|
refresh_token: this.credentials.refresh_token,
|
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
|
};
|
|
|
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
Accept: 'application/json',
|
|
},
|
|
body: objectToUrlEncoded(bodyData),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.text();
|
|
// Handle 400 errors which might indicate refresh token expiry
|
|
if (response.status === 400) {
|
|
await clearQwenCredentials();
|
|
throw new CredentialsClearRequiredError(
|
|
"Refresh token expired or invalid. Please use '/auth' to re-authenticate.",
|
|
{ status: response.status, response: errorData },
|
|
);
|
|
}
|
|
throw new Error(
|
|
`Token refresh failed: ${response.status} ${response.statusText}. Response: ${errorData}`,
|
|
);
|
|
}
|
|
|
|
const responseData = (await response.json()) as TokenRefreshResponse;
|
|
|
|
// Check if the response indicates success
|
|
if (isErrorResponse(responseData)) {
|
|
const errorData = responseData as ErrorData;
|
|
throw new Error(
|
|
`Token refresh failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
|
);
|
|
}
|
|
|
|
// Handle successful response
|
|
const tokenData = responseData as TokenRefreshData;
|
|
const tokens: QwenCredentials = {
|
|
access_token: tokenData.access_token,
|
|
token_type: tokenData.token_type,
|
|
// Use new refresh token if provided, otherwise preserve existing one
|
|
refresh_token: tokenData.refresh_token || this.credentials.refresh_token,
|
|
resource_url: tokenData.resource_url, // Include resource_url if provided
|
|
expiry_date: Date.now() + tokenData.expires_in * 1000,
|
|
};
|
|
|
|
this.setCredentials(tokens);
|
|
|
|
// Note: File caching is now handled by SharedTokenManager
|
|
// to prevent cross-session token invalidation issues
|
|
|
|
return responseData;
|
|
}
|
|
}
|
|
|
|
export enum QwenOAuth2Event {
|
|
AuthUri = 'auth-uri',
|
|
AuthProgress = 'auth-progress',
|
|
AuthCancel = 'auth-cancel',
|
|
}
|
|
|
|
/**
|
|
* Authentication result types to distinguish different failure reasons
|
|
*/
|
|
export type AuthResult =
|
|
| { success: true }
|
|
| {
|
|
success: false;
|
|
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
|
|
message?: string; // Detailed error message for better error reporting
|
|
};
|
|
|
|
/**
|
|
* Global event emitter instance for QwenOAuth2 authentication events
|
|
*/
|
|
export const qwenOAuth2Events = new EventEmitter();
|
|
|
|
export async function getQwenOAuthClient(
|
|
config: Config,
|
|
options?: { requireCachedCredentials?: boolean },
|
|
): Promise<QwenOAuth2Client> {
|
|
const client = new QwenOAuth2Client();
|
|
|
|
// Use shared token manager to get valid credentials with cross-session synchronization
|
|
const sharedManager = SharedTokenManager.getInstance();
|
|
|
|
try {
|
|
// Try to get valid credentials from shared cache first
|
|
const credentials = await sharedManager.getValidCredentials(client);
|
|
client.setCredentials(credentials);
|
|
return client;
|
|
} catch (error: unknown) {
|
|
// Handle specific token manager errors
|
|
if (error instanceof TokenManagerError) {
|
|
switch (error.type) {
|
|
case TokenError.NO_REFRESH_TOKEN:
|
|
console.debug(
|
|
'No refresh token available, proceeding with device flow',
|
|
);
|
|
break;
|
|
case TokenError.REFRESH_FAILED:
|
|
console.debug('Token refresh failed, proceeding with device flow');
|
|
break;
|
|
case TokenError.NETWORK_ERROR:
|
|
console.warn(
|
|
'Network error during token refresh, trying device flow',
|
|
);
|
|
break;
|
|
default:
|
|
console.warn('Token manager error:', (error as Error).message);
|
|
}
|
|
}
|
|
|
|
if (options?.requireCachedCredentials) {
|
|
throw new Error(
|
|
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
|
|
);
|
|
}
|
|
|
|
// If we couldn't obtain valid credentials via SharedTokenManager, fall back to
|
|
// interactive device authorization (unless explicitly forbidden above).
|
|
const result = await authWithQwenDeviceFlow(client, config);
|
|
if (!result.success) {
|
|
// Only emit timeout event if the failure reason is actually timeout
|
|
// Other error types (401, 429, etc.) have already emitted their specific events
|
|
if (result.reason === 'timeout') {
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'timeout',
|
|
'Authentication timed out. Please try again or select a different authentication method.',
|
|
);
|
|
}
|
|
|
|
// Use detailed error message if available, otherwise use default based on reason
|
|
const errorMessage =
|
|
result.message ||
|
|
(() => {
|
|
switch (result.reason) {
|
|
case 'timeout':
|
|
return 'Qwen OAuth authentication timed out';
|
|
case 'cancelled':
|
|
return 'Qwen OAuth authentication was cancelled by user';
|
|
case 'rate_limit':
|
|
return 'Too many request for Qwen OAuth authentication, please try again later.';
|
|
case 'error':
|
|
default:
|
|
return 'Qwen OAuth authentication failed';
|
|
}
|
|
})();
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
return client;
|
|
}
|
|
}
|
|
|
|
async function authWithQwenDeviceFlow(
|
|
client: QwenOAuth2Client,
|
|
config: Config,
|
|
): Promise<AuthResult> {
|
|
let isCancelled = false;
|
|
|
|
// Set up cancellation listener
|
|
const cancelHandler = () => {
|
|
isCancelled = true;
|
|
};
|
|
qwenOAuth2Events.once(QwenOAuth2Event.AuthCancel, cancelHandler);
|
|
|
|
try {
|
|
// Generate PKCE code verifier and challenge
|
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
|
|
|
// Request device authorization
|
|
const deviceAuth = await client.requestDeviceAuthorization({
|
|
scope: QWEN_OAUTH_SCOPE,
|
|
code_challenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
// Ensure we have a successful authorization response
|
|
if (!isDeviceAuthorizationSuccess(deviceAuth)) {
|
|
const errorData = deviceAuth as ErrorData;
|
|
throw new Error(
|
|
`Device authorization failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
|
);
|
|
}
|
|
|
|
// Emit device authorization event for UI integration immediately
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, deviceAuth);
|
|
|
|
const showFallbackMessage = () => {
|
|
console.log('\n=== Qwen OAuth Device Authorization ===');
|
|
console.log(
|
|
'Please visit the following URL in your browser to authorize:',
|
|
);
|
|
console.log(`\n${deviceAuth.verification_uri_complete}\n`);
|
|
console.log('Waiting for authorization to complete...\n');
|
|
};
|
|
|
|
// If browser launch is not suppressed, try to open the URL
|
|
if (!config.isBrowserLaunchSuppressed()) {
|
|
try {
|
|
const childProcess = await open(deviceAuth.verification_uri_complete);
|
|
|
|
// IMPORTANT: Attach an error handler to the returned child process.
|
|
// Without this, if `open` fails to spawn a process (e.g., `xdg-open` is not found
|
|
// in a minimal Docker container), it will emit an unhandled 'error' event,
|
|
// causing the entire Node.js process to crash.
|
|
if (childProcess) {
|
|
childProcess.on('error', () => {
|
|
console.debug(
|
|
'Failed to open browser. Visit this URL to authorize:',
|
|
);
|
|
showFallbackMessage();
|
|
});
|
|
}
|
|
} catch (_err) {
|
|
showFallbackMessage();
|
|
}
|
|
} else {
|
|
// Browser launch is suppressed, show fallback message
|
|
showFallbackMessage();
|
|
}
|
|
|
|
// Emit auth progress event
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'polling',
|
|
'Waiting for authorization...',
|
|
);
|
|
|
|
console.debug('Waiting for authorization...\n');
|
|
|
|
// Poll for the token
|
|
let pollInterval = 2000; // 2 seconds, can be increased if slow_down is received
|
|
const maxAttempts = Math.ceil(
|
|
deviceAuth.expires_in / (pollInterval / 1000),
|
|
);
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
// Check if authentication was cancelled
|
|
if (isCancelled) {
|
|
const message = 'Authentication cancelled by user.';
|
|
console.debug('\n' + message);
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
|
return { success: false, reason: 'cancelled', message };
|
|
}
|
|
|
|
try {
|
|
console.debug('polling for token...');
|
|
const tokenResponse = await client.pollDeviceToken({
|
|
device_code: deviceAuth.device_code,
|
|
code_verifier,
|
|
});
|
|
|
|
// Check if the response is successful and contains token data
|
|
if (isDeviceTokenSuccess(tokenResponse)) {
|
|
const tokenData = tokenResponse as DeviceTokenData;
|
|
|
|
// Convert to QwenCredentials format
|
|
const credentials: QwenCredentials = {
|
|
access_token: tokenData.access_token!, // Safe to assert as non-null due to isDeviceTokenSuccess check
|
|
refresh_token: tokenData.refresh_token || undefined,
|
|
token_type: tokenData.token_type,
|
|
resource_url: tokenData.resource_url,
|
|
expiry_date: tokenData.expires_in
|
|
? Date.now() + tokenData.expires_in * 1000
|
|
: undefined,
|
|
};
|
|
|
|
client.setCredentials(credentials);
|
|
|
|
// Cache the new tokens
|
|
await cacheQwenCredentials(credentials);
|
|
|
|
// IMPORTANT:
|
|
// SharedTokenManager maintains an in-memory cache and throttles file checks.
|
|
// If we only write the creds file here, a subsequent `getQwenOAuthClient()`
|
|
// call in the same process (within the throttle window) may not re-read the
|
|
// updated file and could incorrectly re-trigger device auth.
|
|
// Clearing the cache forces the next call to reload from disk.
|
|
try {
|
|
SharedTokenManager.getInstance().clearCache();
|
|
} catch {
|
|
// In unit tests we sometimes mock SharedTokenManager.getInstance() with a
|
|
// minimal stub; cache invalidation is best-effort and should not break auth.
|
|
}
|
|
|
|
// Emit auth progress success event
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'success',
|
|
'Authentication successful! Access token obtained.',
|
|
);
|
|
|
|
console.debug('Authentication successful! Access token obtained.');
|
|
return { success: true };
|
|
}
|
|
|
|
// Check if the response is pending
|
|
if (isDeviceTokenPending(tokenResponse)) {
|
|
const pendingData = tokenResponse as DeviceTokenPendingData;
|
|
|
|
// Handle slow_down error by increasing poll interval
|
|
if (pendingData.slowDown) {
|
|
pollInterval = Math.min(pollInterval * 1.5, 10000); // Increase by 50%, max 10 seconds
|
|
console.debug(
|
|
`\nServer requested to slow down, increasing poll interval to ${pollInterval}ms'`,
|
|
);
|
|
} else {
|
|
pollInterval = 2000; // Reset to default interval
|
|
}
|
|
|
|
// Emit polling progress event
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'polling',
|
|
`Polling... (attempt ${attempt + 1}/${maxAttempts})`,
|
|
);
|
|
|
|
// Wait with cancellation check every 100ms
|
|
await new Promise<void>((resolve) => {
|
|
const checkInterval = 100; // Check every 100ms
|
|
let elapsedTime = 0;
|
|
|
|
const intervalId = setInterval(() => {
|
|
elapsedTime += checkInterval;
|
|
|
|
// Check for cancellation during wait
|
|
if (isCancelled) {
|
|
clearInterval(intervalId);
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Complete wait when interval is reached
|
|
if (elapsedTime >= pollInterval) {
|
|
clearInterval(intervalId);
|
|
resolve();
|
|
return;
|
|
}
|
|
}, checkInterval);
|
|
});
|
|
|
|
// Check for cancellation after waiting
|
|
if (isCancelled) {
|
|
const message = 'Authentication cancelled by user.';
|
|
console.debug('\n' + message);
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'error',
|
|
message,
|
|
);
|
|
return { success: false, reason: 'cancelled', message };
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
// Handle error response
|
|
if (isErrorResponse(tokenResponse)) {
|
|
const errorData = tokenResponse as ErrorData;
|
|
throw new Error(
|
|
`Token polling failed: ${errorData?.error || 'Unknown error'} - ${errorData?.error_description || 'No details provided'}`,
|
|
);
|
|
}
|
|
} catch (error: unknown) {
|
|
// Extract error information
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
const statusCode =
|
|
error instanceof Error
|
|
? (error as Error & { status?: number }).status
|
|
: null;
|
|
|
|
// Helper function to handle error and stop polling
|
|
const handleError = (
|
|
reason: 'error' | 'rate_limit',
|
|
message: string,
|
|
eventType: 'error' | 'rate_limit' = 'error',
|
|
): AuthResult => {
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
eventType,
|
|
message,
|
|
);
|
|
console.error('\n' + message);
|
|
return { success: false, reason, message };
|
|
};
|
|
|
|
// Handle credential caching failures - stop polling immediately
|
|
if (errorMessage.includes('Failed to cache credentials')) {
|
|
return handleError('error', errorMessage);
|
|
}
|
|
|
|
// Handle 401 Unauthorized - device code expired or invalid
|
|
if (errorMessage.includes('401') || statusCode === 401) {
|
|
return handleError(
|
|
'error',
|
|
'Device code expired or invalid, please restart the authorization process.',
|
|
);
|
|
}
|
|
|
|
// Handle 429 Too Many Requests - rate limiting
|
|
if (errorMessage.includes('429') || statusCode === 429) {
|
|
return handleError(
|
|
'rate_limit',
|
|
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
|
|
'rate_limit',
|
|
);
|
|
}
|
|
|
|
const message = `Error polling for token: ${errorMessage}`;
|
|
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
|
|
|
|
if (isCancelled) {
|
|
const message = 'Authentication cancelled by user.';
|
|
return { success: false, reason: 'cancelled', message };
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
}
|
|
}
|
|
|
|
const timeoutMessage = 'Authorization timeout, please restart the process.';
|
|
|
|
// Emit timeout error event
|
|
qwenOAuth2Events.emit(
|
|
QwenOAuth2Event.AuthProgress,
|
|
'timeout',
|
|
timeoutMessage,
|
|
);
|
|
|
|
console.error('\n' + timeoutMessage);
|
|
return { success: false, reason: 'timeout', message: timeoutMessage };
|
|
} catch (error: unknown) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const message = `Device authorization flow failed: ${errorMessage}`;
|
|
console.error(message);
|
|
return { success: false, reason: 'error', message };
|
|
} finally {
|
|
// Clean up event listener
|
|
qwenOAuth2Events.off(QwenOAuth2Event.AuthCancel, cancelHandler);
|
|
}
|
|
}
|
|
|
|
async function cacheQwenCredentials(credentials: QwenCredentials) {
|
|
const filePath = getQwenCachedCredentialPath();
|
|
try {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
|
|
const credString = JSON.stringify(credentials, null, 2);
|
|
await fs.writeFile(filePath, credString);
|
|
} catch (error: unknown) {
|
|
// Handle file system errors (e.g., EACCES permission denied)
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorCode =
|
|
error instanceof Error && 'code' in error
|
|
? (error as Error & { code?: string }).code
|
|
: undefined;
|
|
|
|
if (errorCode === 'EACCES') {
|
|
throw new Error(
|
|
`Failed to cache credentials: Permission denied (EACCES). Current user has no permission to access \`${filePath}\`. Please check permissions.`,
|
|
);
|
|
}
|
|
|
|
// Throw error for other file system failures
|
|
throw new Error(
|
|
`Failed to cache credentials: error when creating folder \`${path.dirname(filePath)}\` and writing to \`${filePath}\`. ${errorMessage}. Please check permissions.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear cached Qwen credentials from disk
|
|
* This is useful when credentials have expired or need to be reset
|
|
*/
|
|
export async function clearQwenCredentials(): Promise<void> {
|
|
try {
|
|
const filePath = getQwenCachedCredentialPath();
|
|
await fs.unlink(filePath);
|
|
console.debug('Cached Qwen credentials cleared successfully.');
|
|
} catch (error: unknown) {
|
|
// If file doesn't exist or can't be deleted, we consider it cleared
|
|
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
// File doesn't exist, already cleared
|
|
return;
|
|
}
|
|
// Log other errors but don't throw - clearing credentials should be non-critical
|
|
console.warn('Warning: Failed to clear cached Qwen credentials:', error);
|
|
} finally {
|
|
// Also clear SharedTokenManager in-memory cache to prevent stale credentials
|
|
// from being reused within the same process after the file is removed.
|
|
try {
|
|
SharedTokenManager.getInstance().clearCache();
|
|
} catch {
|
|
// Best-effort; don't fail credential clearing if SharedTokenManager is mocked.
|
|
}
|
|
}
|
|
}
|
|
|
|
function getQwenCachedCredentialPath(): string {
|
|
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME);
|
|
}
|