Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 85 additions & 12 deletions src/providers/plugins/sso/sso.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,34 @@ export class CodeMieSSO {
private server?: Server;
private callbackResult?: SSOAuthResult;
private codeMieUrl!: string;
private abortController?: AbortController;
private isAuthenticating = false;

/**
* Authenticate via browser SSO
*/
async authenticate(config: SSOAuthConfig): Promise<SSOAuthResult> {
this.codeMieUrl = config.codeMieUrl;
this.isAuthenticating = true;
this.abortController = new AbortController();

// Register signal handlers for graceful termination (following agent.ts pattern)
const sigintHandler = () => {
if (this.isAuthenticating) {
console.log(chalk.yellow('\n⚠️ Authentication cancelled by user'));
this.abortController?.abort();
}
};

const sigtermHandler = () => {
if (this.isAuthenticating) {
console.log(chalk.yellow('\n⚠️ Authentication terminated'));
this.abortController?.abort();
}
};

process.once('SIGINT', sigintHandler);
process.once('SIGTERM', sigtermHandler);

try {
// 1. Start local callback server
Expand All @@ -53,8 +75,11 @@ export class CodeMieSSO {
console.log(chalk.white(`Opening browser for authentication...`));
await open(ssoUrl);

// 4. Wait for callback with timeout
const result = await this.waitForCallback(config.timeout || 120000);
// 4. Wait for callback with timeout and abort signal
const result = await this.waitForCallback(
config.timeout || 120000,
this.abortController.signal
);

// 5. Store credentials if successful
if (result.success && result.apiUrl && result.cookies) {
Expand All @@ -71,11 +96,25 @@ export class CodeMieSSO {
return result;

} catch (error) {
// Handle abort as user cancellation
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Authentication cancelled by user'
};
}

return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
} finally {
this.isAuthenticating = false;

// Remove signal handlers to prevent memory leaks (following agent.ts pattern)
process.off('SIGINT', sigintHandler);
process.off('SIGTERM', sigtermHandler);

this.cleanup();
}
}
Expand Down Expand Up @@ -292,24 +331,47 @@ export class CodeMieSSO {
}

/**
* Wait for OAuth callback with timeout
* Wait for OAuth callback with timeout and abort support
*/
private async waitForCallback(timeout: number): Promise<SSOAuthResult> {
private async waitForCallback(
timeout: number,
abortSignal: AbortSignal
): Promise<SSOAuthResult> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
let timer: NodeJS.Timeout | undefined;
let pollInterval: NodeJS.Timeout | undefined;

// Handle abort signal
const abortHandler = () => {
if (timer) clearTimeout(timer);
if (pollInterval) clearInterval(pollInterval);
reject(new Error('AbortError'));
};

// Handle timeout
timer = setTimeout(() => {
if (pollInterval) clearInterval(pollInterval);
abortSignal.removeEventListener('abort', abortHandler);
reject(new Error('Authentication timeout - no response received'));
}, timeout);

const checkResult = () => {
// Register abort handler
if (abortSignal.aborted) {
clearTimeout(timer);
reject(new Error('AbortError'));
return;
}
abortSignal.addEventListener('abort', abortHandler);

// Poll for callback result (non-recursive)
pollInterval = setInterval(() => {
if (this.callbackResult) {
clearTimeout(timer);
clearInterval(pollInterval);
abortSignal.removeEventListener('abort', abortHandler);
resolve(this.callbackResult);
} else {
setTimeout(checkResult, 100);
}
};

checkResult();
}, 100);
});
}

Expand All @@ -318,8 +380,19 @@ export class CodeMieSSO {
*/
private cleanup(): void {
if (this.server) {
this.server.close();
// Force close all connections immediately
this.server.closeAllConnections?.();

// Close the server
this.server.close(() => {
// Server closed callback (optional)
});

delete this.server;
}

// Reset state
this.callbackResult = undefined;
this.abortController = undefined;
}
}
19 changes: 18 additions & 1 deletion src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ class Logger {
});
}

// Defensive check: Skip write if rotation is in progress or stream is not writable
// This prevents ERR_STREAM_WRITE_AFTER_END race condition when log rotation occurs
// Race scenario: rotateLogFileIfNeeded() closes stream while writeToLogFile() tries to write
if (this.isRotating || !this.writeStream.writable) {
// Skip this write - next log call will use the new rotated stream
return;
}

try {
const timestamp = new Date().toISOString();

Expand Down Expand Up @@ -210,8 +218,17 @@ class Logger {
/**
* Flush and close the write stream
* Returns a Promise that resolves when all data is flushed
* Waits for any in-progress rotation to complete before closing
*/
close(): Promise<void> {
async close(): Promise<void> {
// Wait for any in-progress rotation to complete
// This prevents race conditions where close() is called during rotation
const maxWaitTime = 5000; // 5 seconds max wait
const startTime = Date.now();
while (this.isRotating && (Date.now() - startTime) < maxWaitTime) {
await new Promise(resolve => setTimeout(resolve, 100));
}

return new Promise((resolve) => {
if (this.writeStream) {
this.writeStream.end(() => {
Expand Down