refactor(auth): save authType after successfully authenticated (#1036)

This commit is contained in:
Mingholy
2025-11-19 11:21:46 +08:00
committed by GitHub
parent 3ed93d5b5d
commit d0e76c76a8
30 changed files with 822 additions and 518 deletions

View File

@@ -30,6 +30,7 @@ export {
logExtensionEnable,
logIdeConnection,
logExtensionDisable,
logAuth,
} from './src/telemetry/loggers.js';
export {
@@ -40,6 +41,7 @@ export {
ExtensionEnableEvent,
ExtensionUninstallEvent,
ModelSlashCommandEvent,
AuthEvent,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';
export * from './src/utils/pathReader.js';

View File

@@ -562,7 +562,7 @@ export class Config {
}
}
async refreshAuth(authMethod: AuthType) {
async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) {
// Vertex and Genai have incompatible encryption and sending history with
// throughtSignature from Genai to Vertex will fail, we need to strip them
if (
@@ -582,6 +582,7 @@ export class Config {
newContentGeneratorConfig,
this,
this.getSessionId(),
isInitialAuth,
);
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;

View File

@@ -120,6 +120,7 @@ export async function createContentGenerator(
config: ContentGeneratorConfig,
gcConfig: Config,
sessionId?: string,
isInitialAuth?: boolean,
): Promise<ContentGenerator> {
const version = process.env['CLI_VERSION'] || process.version;
const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`;
@@ -191,13 +192,17 @@ export async function createContentGenerator(
try {
// Get the Qwen OAuth client (now includes integrated token management)
const qwenClient = await getQwenOauthClient(gcConfig);
// If this is initial auth, require cached credentials to detect missing credentials
const qwenClient = await getQwenOauthClient(
gcConfig,
isInitialAuth ? { requireCachedCredentials: true } : undefined,
);
// Create the content generator with dynamic token management
return new QwenContentGenerator(qwenClient, config, gcConfig);
} catch (error) {
throw new Error(
`Failed to initialize Qwen: ${error instanceof Error ? error.message : String(error)}`,
`${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -825,7 +825,7 @@ describe('getQwenOAuthClient', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -983,7 +983,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1032,7 +1032,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication timed out');
).rejects.toThrow('Authorization timeout, please restart the process.');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1082,7 +1082,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow(
'Too many request for Qwen OAuth authentication, please try again later.',
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.',
);
SharedTokenManager.getInstance = originalGetInstance;
@@ -1119,7 +1119,7 @@ describe('getQwenOAuthClient - Enhanced Error Scenarios', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1177,7 +1177,7 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow('Device authorization flow failed');
SharedTokenManager.getInstance = originalGetInstance;
});
@@ -1264,7 +1264,9 @@ describe('authWithQwenDeviceFlow - Comprehensive Testing', () => {
import('./qwenOAuth2.js').then((module) =>
module.getQwenOAuthClient(mockConfig),
),
).rejects.toThrow('Qwen OAuth authentication failed');
).rejects.toThrow(
'Device code expired or invalid, please restart the authorization process.',
);
SharedTokenManager.getInstance = originalGetInstance;
});

View File

@@ -467,6 +467,7 @@ export type AuthResult =
| {
success: false;
reason: 'timeout' | 'cancelled' | 'error' | 'rate_limit';
message?: string; // Detailed error message for better error reporting
};
/**
@@ -476,6 +477,7 @@ export const qwenOAuth2Events = new EventEmitter();
export async function getQwenOAuthClient(
config: Config,
options?: { requireCachedCredentials?: boolean },
): Promise<QwenOAuth2Client> {
const client = new QwenOAuth2Client();
@@ -488,11 +490,6 @@ export async function getQwenOAuthClient(
client.setCredentials(credentials);
return client;
} catch (error: unknown) {
console.debug(
'Shared token manager failed, attempting device flow:',
error,
);
// Handle specific token manager errors
if (error instanceof TokenManagerError) {
switch (error.type) {
@@ -520,12 +517,20 @@ export async function getQwenOAuthClient(
// Try device flow instead of forcing refresh
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
throw new Error('Qwen OAuth authentication failed');
// Use detailed error message if available, otherwise use default
const errorMessage =
result.message || 'Qwen OAuth authentication failed';
throw new Error(errorMessage);
}
return client;
}
// No cached credentials, use device authorization flow for authentication
if (options?.requireCachedCredentials) {
throw new Error(
'No cached Qwen-OAuth credentials found. Please re-authenticate.',
);
}
const result = await authWithQwenDeviceFlow(client, config);
if (!result.success) {
// Only emit timeout event if the failure reason is actually timeout
@@ -538,20 +543,24 @@ export async function getQwenOAuthClient(
);
}
// Throw error with appropriate message based on failure reason
switch (result.reason) {
case 'timeout':
throw new Error('Qwen OAuth authentication timed out');
case 'cancelled':
throw new Error('Qwen OAuth authentication was cancelled by user');
case 'rate_limit':
throw new Error(
'Too many request for Qwen OAuth authentication, please try again later.',
);
case 'error':
default:
throw new Error('Qwen OAuth authentication failed');
}
// 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;
@@ -644,13 +653,10 @@ async function authWithQwenDeviceFlow(
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Check if authentication was cancelled
if (isCancelled) {
console.debug('\nAuthentication cancelled by user.');
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
'Authentication cancelled by user.',
);
return { success: false, reason: 'cancelled' };
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'cancelled', message };
}
try {
@@ -738,13 +744,14 @@ async function authWithQwenDeviceFlow(
// Check for cancellation after waiting
if (isCancelled) {
console.debug('\nAuthentication cancelled by user.');
const message = 'Authentication cancelled by user.';
console.debug('\n' + message);
qwenOAuth2Events.emit(
QwenOAuth2Event.AuthProgress,
'error',
'Authentication cancelled by user.',
message,
);
return { success: false, reason: 'cancelled' };
return { success: false, reason: 'cancelled', message };
}
continue;
@@ -758,7 +765,7 @@ async function authWithQwenDeviceFlow(
);
}
} catch (error: unknown) {
// Handle specific error cases
// Extract error information
const errorMessage =
error instanceof Error ? error.message : String(error);
const statusCode =
@@ -766,42 +773,49 @@ async function authWithQwenDeviceFlow(
? (error as Error & { status?: number }).status
: null;
if (errorMessage.includes('401') || statusCode === 401) {
const message =
'Device code expired or invalid, please restart the authorization process.';
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
return { success: false, reason: 'error' };
}
// Handle 429 Too Many Requests error
if (errorMessage.includes('429') || statusCode === 429) {
const message =
'Too many requests. The server is rate limiting our requests. Please select a different authentication method or try again later.';
// Emit rate limit event to notify user
// 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,
'rate_limit',
eventType,
message,
);
console.error('\n' + message);
return { success: false, reason, message };
};
console.log('\n' + message);
// Handle credential caching failures - stop polling immediately
if (errorMessage.includes('Failed to cache credentials')) {
return handleError('error', errorMessage);
}
// Return false to stop polling and go back to auth selection
return { success: false, reason: 'rate_limit' };
// 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}`;
// Emit error event
qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', message);
// Check for cancellation before waiting
if (isCancelled) {
return { success: false, reason: 'cancelled' };
const message = 'Authentication cancelled by user.';
return { success: false, reason: 'cancelled', message };
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
@@ -818,11 +832,12 @@ async function authWithQwenDeviceFlow(
);
console.error('\n' + timeoutMessage);
return { success: false, reason: 'timeout' };
return { success: false, reason: 'timeout', message: timeoutMessage };
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('Device authorization flow failed:', errorMessage);
return { success: false, reason: '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);
@@ -852,10 +867,30 @@ async function loadCachedQwenCredentials(
async function cacheQwenCredentials(credentials: QwenCredentials) {
const filePath = getQwenCachedCredentialPath();
await fs.mkdir(path.dirname(filePath), { recursive: true });
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const credString = JSON.stringify(credentials, null, 2);
await fs.writeFile(filePath, credString);
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.`,
);
}
}
/**

View File

@@ -33,6 +33,7 @@ export const EVENT_MALFORMED_JSON_RESPONSE =
export const EVENT_FILE_OPERATION = 'qwen-code.file_operation';
export const EVENT_MODEL_SLASH_COMMAND = 'qwen-code.slash_command.model';
export const EVENT_SUBAGENT_EXECUTION = 'qwen-code.subagent_execution';
export const EVENT_AUTH = 'qwen-code.auth';
// Performance Events
export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance';

View File

@@ -43,6 +43,7 @@ export {
logExtensionUninstall,
logRipgrepFallback,
logNextSpeakerCheck,
logAuth,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';
export {
@@ -61,6 +62,7 @@ export {
ToolOutputTruncatedEvent,
RipgrepFallbackEvent,
NextSpeakerCheckEvent,
AuthEvent,
} from './types.js';
export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js';
export type { TelemetryEvent } from './types.js';

View File

@@ -37,6 +37,7 @@ import {
EVENT_SUBAGENT_EXECUTION,
EVENT_MALFORMED_JSON_RESPONSE,
EVENT_INVALID_CHUNK,
EVENT_AUTH,
} from './constants.js';
import {
recordApiErrorMetrics,
@@ -83,6 +84,7 @@ import type {
SubagentExecutionEvent,
MalformedJsonResponseEvent,
InvalidChunkEvent,
AuthEvent,
} from './types.js';
import type { UiEvent } from './uiTelemetry.js';
import { uiTelemetryService } from './uiTelemetry.js';
@@ -838,3 +840,29 @@ export function logExtensionDisable(
};
logger.emit(logRecord);
}
export function logAuth(config: Config, event: AuthEvent): void {
QwenLogger.getInstance(config)?.logAuthEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_AUTH,
'event.timestamp': new Date().toISOString(),
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
attributes['error.message'] = event.error_message;
}
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Auth event: ${event.action_type} ${event.status} for ${event.auth_type}`,
attributes,
};
logger.emit(logRecord);
}

View File

@@ -37,6 +37,7 @@ import type {
ExtensionEnableEvent,
ModelSlashCommandEvent,
ExtensionDisableEvent,
AuthEvent,
} from '../types.js';
import { EndSessionEvent } from '../types.js';
import type {
@@ -746,6 +747,25 @@ export class QwenLogger {
this.flushIfNeeded();
}
logAuthEvent(event: AuthEvent): void {
const snapshots: Record<string, unknown> = {
auth_type: event.auth_type,
action_type: event.action_type,
status: event.status,
};
if (event.error_message) {
snapshots['error_message'] = event.error_message;
}
const rumEvent = this.createActionEvent('auth', 'auth', {
snapshots: JSON.stringify(snapshots),
});
this.enqueueLogEvent(rumEvent);
this.flushIfNeeded();
}
// misc events
logFlashFallbackEvent(event: FlashFallbackEvent): void {
const rumEvent = this.createActionEvent('misc', 'flash_fallback', {

View File

@@ -686,6 +686,29 @@ export class SubagentExecutionEvent implements BaseTelemetryEvent {
}
}
export class AuthEvent implements BaseTelemetryEvent {
'event.name': 'auth';
'event.timestamp': string;
auth_type: AuthType;
action_type: 'auto' | 'manual';
status: 'success' | 'error' | 'cancelled';
error_message?: string;
constructor(
auth_type: AuthType,
action_type: 'auto' | 'manual',
status: 'success' | 'error' | 'cancelled',
error_message?: string,
) {
this['event.name'] = 'auth';
this['event.timestamp'] = new Date().toISOString();
this.auth_type = auth_type;
this.action_type = action_type;
this.status = status;
this.error_message = error_message;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -713,7 +736,8 @@ export type TelemetryEvent =
| ExtensionInstallEvent
| ExtensionUninstallEvent
| ToolOutputTruncatedEvent
| ModelSlashCommandEvent;
| ModelSlashCommandEvent
| AuthEvent;
export class ExtensionDisableEvent implements BaseTelemetryEvent {
'event.name': 'extension_disable';