/** * Supabase Authentication orchestrator for VC-SYS CLI * Handles OAuth PKCE flow with browser launching */ import crypto from 'crypto'; import open from 'open'; import chalk from 'chalk'; import ora from 'ora'; import { TokenManager } from './token-manager'; import { OAuthServer } from './oauth-server'; import { Logger } from '../core/logger'; export interface AuthenticationResult { success: boolean; userId?: string; message: string; } export interface AuthInfo { isAuthenticated: boolean; expiresAt?: Date; supabaseUserId?: string; tokenPath: string; } export class SupabaseAuth { private tokenManager: TokenManager; private logger: Logger; constructor() { this.tokenManager = new TokenManager(); this.logger = new Logger('SupabaseAuth'); } /** * PKCE utilities - EXACT PORT from frontend */ private generateCodeVerifier(): string { return crypto.randomBytes(32).toString('base64url'); } private generateCodeChallenge(verifier: string): string { const hash = crypto.createHash('sha256').update(verifier).digest(); return hash.toString('base64url'); } private generateState(): string { return crypto.randomBytes(16).toString('base64url'); } /** * Main OAuth authentication flow - adapted from frontend API routes */ async authenticate(): Promise { console.log(chalk.blue('šŸ” Starting Supabase authentication...\n')); const spinner = ora('Preparing OAuth flow').start(); let oauthServer: OAuthServer | undefined; try { // Check if already authenticated if (await this.tokenManager.hasValidTokens()) { spinner.warn('Already authenticated'); console.log(chalk.yellow('āœ… You are already authenticated with Supabase')); console.log(chalk.dim('Use "vcsys auth logout" to sign out first if you want to re-authenticate')); return { success: true, message: 'Already authenticated' }; } // Step 1: Start local callback server oauthServer = new OAuthServer(); const callbackPromise = oauthServer.startServer(); spinner.text = 'Generating secure authentication parameters'; // Step 2: Generate PKCE parameters (SAME AS FRONTEND) const codeVerifier = this.generateCodeVerifier(); const codeChallenge = this.generateCodeChallenge(codeVerifier); const state = this.generateState(); // Step 3: Store flow state (SAME AS FRONTEND) await this.tokenManager.storeFlowState({ codeVerifier, state }); spinner.text = 'Opening browser for Supabase authentication'; // Step 4: Build OAuth URL (SAME AS FRONTEND) const clientId = process.env.VCSYS_OAUTH_CLIENT_ID || '37038b8c-e67b-4029-8c94-9497b94747d4'; const redirectUri = oauthServer.getRedirectUri(); const authUrl = new URL('https://api.supabase.com/v1/oauth/authorize'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('scope', 'all'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // Step 5: Open browser and wait for callback spinner.succeed('Opening browser for authentication'); console.log(chalk.dim('🌐 If browser doesn\'t open automatically, visit:')); console.log(chalk.dim(` ${authUrl.toString()}\n`)); // Cross-platform browser opening try { await open(authUrl.toString()); } catch (error) { this.logger.warn('Failed to open browser automatically', error); console.log(chalk.yellow('āš ļø Could not open browser automatically. Please visit the URL above manually.')); } const callbackSpinner = ora('Waiting for authentication in browser...').start(); // Set up timeout for callback const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Authentication timeout - no callback received within 5 minutes')); }, 5 * 60 * 1000); // 5 minute timeout }); // Wait for OAuth callback or timeout const callbackData = await Promise.race([callbackPromise, timeoutPromise]); if (callbackData.error) { callbackSpinner.fail('Authentication failed'); this.logger.error('OAuth error received', { error: callbackData.error }); return { success: false, message: `OAuth error: ${callbackData.error}` }; } if (!callbackData.code || !callbackData.state) { callbackSpinner.fail('Authentication failed'); this.logger.error('Invalid OAuth callback parameters', callbackData); return { success: false, message: 'Invalid OAuth callback parameters' }; } callbackSpinner.text = 'Verifying authentication'; // Step 6: Validate state and exchange code for tokens const result = await this.exchangeCodeForTokens(callbackData.code, callbackData.state); if (result.success) { callbackSpinner.succeed('Authentication completed successfully!'); console.log(chalk.green('\nāœ… Supabase account connected')); console.log(chalk.dim('Next steps:')); console.log(chalk.dim(' • vcsys auth status - Check authentication')); console.log(chalk.dim(' • vcsys provision - Create Supabase project')); return result; } else { callbackSpinner.fail('Token exchange failed'); return result; } } catch (error) { spinner.fail('Authentication failed'); this.logger.error('Authentication error', error); return { success: false, message: `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}` }; } finally { // Cleanup server if (oauthServer) { oauthServer.cleanup(); } } } /** * Token exchange - EXACT PORT from frontend callback route */ private async exchangeCodeForTokens(code: string, state: string): Promise { try { // Validate state parameter (SAME AS FRONTEND) const flowState = await this.tokenManager.getAndClearFlowState(); if (!flowState || flowState.state !== state) { this.logger.error('OAuth state validation failed', { expectedState: flowState?.state, receivedState: state }); return { success: false, message: 'Invalid OAuth state - possible security issue' }; } // Get OAuth configuration (SAME AS FRONTEND) const clientId = process.env.VCSYS_OAUTH_CLIENT_ID || '37038b8c-e67b-4029-8c94-9497b94747d4'; const clientSecret = process.env.VCSYS_OAUTH_CLIENT_SECRET || 'sba_f80efef960a0e675106581c56437229ddcc11d9b'; const redirectUri = 'http://localhost:8080/callback'; // Default, will be overridden by server // Use node-fetch for consistency const { default: fetch } = await import('node-fetch'); // Exchange code for tokens (EXACT SAME LOGIC AS FRONTEND) const tokenResponse = await fetch('https://api.supabase.com/v1/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json', }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, client_secret: clientSecret, code: code, redirect_uri: redirectUri, code_verifier: flowState.codeVerifier, // PKCE verification }).toString(), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); this.logger.error('Token exchange failed', { status: tokenResponse.status, statusText: tokenResponse.statusText, error: errorText }); return { success: false, message: `Token exchange failed: ${tokenResponse.status} ${errorText}` }; } const tokens: any = await tokenResponse.json(); if (!tokens.access_token || !tokens.refresh_token) { this.logger.error('Invalid token response', { tokens: Object.keys(tokens) }); return { success: false, message: 'Invalid token response from Supabase' }; } // Calculate expiration date (SAME AS FRONTEND) const expiresAt = tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : undefined; // Get Supabase user info (SAME AS FRONTEND) let supabaseUserId: string | undefined; try { const userResponse = await fetch('https://api.supabase.com/v1/profile', { headers: { 'Authorization': `Bearer ${tokens.access_token}`, 'Accept': 'application/json', }, }); if (userResponse.ok) { const userInfo: any = await userResponse.json(); supabaseUserId = userInfo.id; this.logger.info('User info retrieved', { userId: supabaseUserId }); } } catch (error) { this.logger.warn('Failed to fetch user info', error); } // Store tokens (SAME AS FRONTEND LOGIC) await this.tokenManager.storeTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt, }, supabaseUserId); this.logger.info('Authentication successful', { hasUserId: !!supabaseUserId, expiresAt: expiresAt?.toISOString() }); return { success: true, userId: supabaseUserId, message: 'Authentication successful' }; } catch (error) { this.logger.error('Token exchange failed', error); return { success: false, message: `Token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}` }; } } /** * Check authentication status */ async isAuthenticated(): Promise { return this.tokenManager.hasValidTokens(); } /** * Get current authentication info */ async getAuthInfo(): Promise { return this.tokenManager.getAuthInfo(); } /** * Clear authentication (logout) */ async logout(): Promise { const result = await this.tokenManager.clearAllTokens(); if (result) { this.logger.info('Logout successful'); } return result; } /** * Refresh tokens if needed */ async ensureValidToken(): Promise { try { const tokens = await this.tokenManager.getTokens(); if (!tokens) { return null; } // Check if token needs refreshing (within 10 minutes of expiry) if (tokens.expiresAt) { const tenMinutesFromNow = new Date(Date.now() + 10 * 60 * 1000); if (tokens.expiresAt <= tenMinutesFromNow) { this.logger.info('Refreshing tokens'); const refreshedTokens = await this.tokenManager.refreshTokens(); return refreshedTokens.accessToken; } } return tokens.accessToken; } catch (error) { this.logger.error('Failed to ensure valid token', error); return null; } } }