diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3e694daa..5b55e8b7 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,4 +1,5 @@ import { shell } from 'electron'; +import type { Server } from 'http'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; @@ -14,12 +15,48 @@ import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; // Store active OAuth flows (state -> { codeVerifier, provider, config }) -const activeFlows = new Map(); +// Module-level state for tracking the active OAuth flow +interface ActiveOAuthFlow { + provider: string; + state: string; + server: Server; + cleanupTimeout: NodeJS.Timeout; +} + +let activeFlow: ActiveOAuthFlow | null = null; + +/** + * Cancel any active OAuth flow, cleaning up resources + */ +function cancelActiveFlow(reason: string = 'cancelled'): void { + if (!activeFlow) { + return; + } + + console.log(`[OAuth] Cancelling active flow for ${activeFlow.provider}: ${reason}`); + + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlows.delete(activeFlow.state); + + // Only emit event for user-visible cancellations + if (reason !== 'new_flow_started') { + emitOAuthEvent({ + provider: activeFlow.provider, + success: false, + error: `OAuth flow ${reason}` + }); + } + + activeFlow = null; +} + /** * Get OAuth repository from DI container */ @@ -98,6 +135,10 @@ async function getProviderConfiguration(provider: string): Promise { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); + + // Cancel any existing flow before starting a new one + cancelActiveFlow('new_flow_started'); + const oauthRepo = getOAuthRepo(); const providerConfig = getProviderConfig(provider); @@ -122,9 +163,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool state, }); - // Declare timeout variable (will be set after server is created) - let cleanupTimeout: NodeJS.Timeout; - // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { // Validate state @@ -140,7 +178,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool try { // Build callback URL for token exchange const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`); - + // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthClient.exchangeCodeForTokens( @@ -172,21 +210,30 @@ export async function connectProvider(provider: string): Promise<{ success: bool } finally { // Clean up activeFlows.delete(state); - server.close(); - clearTimeout(cleanupTimeout); + if (activeFlow && activeFlow.state === state) { + clearTimeout(activeFlow.cleanupTimeout); + activeFlow.server.close(); + activeFlow = null; + } } }); - // Set timeout to clean up abandoned flows (5 minutes) + // Set timeout to clean up abandoned flows (2 minutes) // This prevents memory leaks if user never completes the OAuth flow - cleanupTimeout = setTimeout(() => { - if (activeFlows.has(state)) { + const cleanupTimeout = setTimeout(() => { + if (activeFlow?.state === state) { console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); - activeFlows.delete(state); - server.close(); - emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' }); + cancelActiveFlow('timed_out'); } - }, 5 * 60 * 1000); // 5 minutes + }, 2 * 60 * 1000); // 2 minutes + + // Store complete flow state for cleanup + activeFlow = { + provider, + state, + server, + cleanupTimeout, + }; // Open in system browser (shares cookies/sessions with user's regular browser) shell.openExternal(authUrl.toString());