/** * Token Manager for VC-SYS CLI - TypeScript port from frontend * Handles secure token storage with AES-256-GCM encryption */ import fs from 'fs-extra'; import path from 'path'; import os from 'os'; import { encryptionManager } from '../utils/encryption'; import { Logger } from '../core/logger'; export interface TokenData { accessToken: string; refreshToken: string; expiresAt?: Date; supabaseUserId?: string; } export interface EncryptedTokenData { accessToken: string; refreshToken: string; expiresAt?: string; supabaseUserId?: string; storedAt: string; } export interface OAuthFlowState { codeVerifier: string; state: string; createdAt: string; } export class TokenManager { private authDir: string; private tokensPath: string; private flowStatePath: string; private logger: Logger; constructor() { this.logger = new Logger('TokenManager'); this.authDir = path.join(os.homedir(), '.vcsys', 'auth'); this.tokensPath = path.join(this.authDir, 'tokens.json'); this.flowStatePath = path.join(this.authDir, 'flow-state.json'); } /** * Store OAuth tokens with encryption - equivalent to frontend storeOAuthTokens() */ async storeTokens(tokens: TokenData, supabaseUserId?: string): Promise { try { await fs.ensureDir(this.authDir); const encryptedTokens: EncryptedTokenData = { accessToken: encryptionManager.encrypt(tokens.accessToken), refreshToken: encryptionManager.encrypt(tokens.refreshToken), expiresAt: tokens.expiresAt ? tokens.expiresAt.toISOString() : undefined, supabaseUserId: supabaseUserId ? encryptionManager.encrypt(supabaseUserId) : undefined, storedAt: new Date().toISOString() }; // Write with user-only permissions await fs.writeJson(this.tokensPath, encryptedTokens, { spaces: 2, mode: 0o600 }); this.logger.info('Tokens stored successfully', { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken, hasUserId: !!supabaseUserId }); } catch (error) { this.logger.error('Failed to store tokens', error); throw new Error(`Failed to store authentication tokens: ${error}`); } } /** * Retrieve OAuth tokens - equivalent to frontend getOAuthTokens() */ async getTokens(): Promise { try { if (!(await fs.pathExists(this.tokensPath))) { return null; } const encryptedTokens: EncryptedTokenData = await fs.readJson(this.tokensPath); if (!encryptedTokens.accessToken || !encryptedTokens.refreshToken) { this.logger.warn('Invalid token file structure'); return null; } return { accessToken: encryptionManager.decrypt(encryptedTokens.accessToken), refreshToken: encryptionManager.decrypt(encryptedTokens.refreshToken), expiresAt: encryptedTokens.expiresAt ? new Date(encryptedTokens.expiresAt) : undefined, supabaseUserId: encryptedTokens.supabaseUserId ? encryptionManager.decrypt(encryptedTokens.supabaseUserId) : undefined }; } catch (error) { this.logger.warn('Failed to decrypt tokens', error); return null; } } /** * Store OAuth flow state - equivalent to frontend storeOAuthFlowState() */ async storeFlowState(flowState: { codeVerifier: string; state: string }): Promise { try { await fs.ensureDir(this.authDir); const stateData: OAuthFlowState = { codeVerifier: flowState.codeVerifier, state: flowState.state, createdAt: new Date().toISOString() }; await fs.writeJson(this.flowStatePath, stateData, { spaces: 2, mode: 0o600 }); this.logger.debug('OAuth flow state stored'); } catch (error) { this.logger.error('Failed to store OAuth flow state', error); throw new Error(`Failed to store OAuth flow state: ${error}`); } } /** * Retrieve and clear OAuth flow state - equivalent to frontend getAndClearOAuthFlowState() */ async getAndClearFlowState(): Promise<{ codeVerifier: string; state: string } | null> { try { if (!(await fs.pathExists(this.flowStatePath))) { return null; } const flowState: OAuthFlowState = await fs.readJson(this.flowStatePath); if (!flowState.codeVerifier || !flowState.state) { this.logger.warn('Invalid flow state file structure'); return null; } // Clear the flow state file after retrieval await fs.remove(this.flowStatePath); this.logger.debug('OAuth flow state retrieved and cleared'); return { codeVerifier: flowState.codeVerifier, state: flowState.state }; } catch (error) { this.logger.warn('Failed to read OAuth flow state', error); return null; } } /** * Clear all tokens and flow state - equivalent to frontend revokeOAuthTokens() */ async clearAllTokens(): Promise { try { const promises = []; if (await fs.pathExists(this.tokensPath)) { promises.push(fs.remove(this.tokensPath)); } if (await fs.pathExists(this.flowStatePath)) { promises.push(fs.remove(this.flowStatePath)); } await Promise.all(promises); this.logger.info('All authentication data cleared'); return true; } catch (error) { this.logger.error('Failed to clear tokens', error); return false; } } /** * Check if we have valid tokens - equivalent to frontend hasValidOAuthTokens() */ async hasValidTokens(): Promise { const tokens = await this.getTokens(); if (!tokens?.accessToken) { return false; } // Check if token is expired (with 5 minute buffer) if (tokens.expiresAt) { const fiveMinutesFromNow = new Date(Date.now() + 5 * 60 * 1000); return tokens.expiresAt > fiveMinutesFromNow; } // If no expiration date, assume valid return true; } /** * Refresh OAuth tokens - direct port of frontend refreshOAuthTokens() */ async refreshTokens(): Promise { const tokens = await this.getTokens(); if (!tokens?.refreshToken) { throw new Error('No refresh token found'); } // 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'; if (!clientId || !clientSecret) { throw new Error('OAuth configuration missing'); } try { // Use node-fetch for consistency with frontend const { default: fetch } = await import('node-fetch'); const refreshResponse = 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: 'refresh_token', client_id: clientId, client_secret: clientSecret, refresh_token: tokens.refreshToken, }).toString(), }); if (!refreshResponse.ok) { const errorText = await refreshResponse.text(); throw new Error(`Token refresh failed: ${refreshResponse.status} ${errorText}`); } const newTokens: any = await refreshResponse.json(); if (!newTokens.access_token) { throw new Error('Invalid refresh response'); } const refreshedTokens: TokenData = { accessToken: newTokens.access_token, refreshToken: newTokens.refresh_token || tokens.refreshToken, expiresAt: newTokens.expires_in ? new Date(Date.now() + newTokens.expires_in * 1000) : undefined, supabaseUserId: tokens.supabaseUserId }; await this.storeTokens(refreshedTokens, tokens.supabaseUserId); this.logger.info('Tokens refreshed successfully'); return refreshedTokens; } catch (error) { this.logger.error('Token refresh failed', error); throw error; } } /** * Get authentication info for status display */ async getAuthInfo(): Promise<{ isAuthenticated: boolean; expiresAt?: Date; supabaseUserId?: string; tokenPath: string; }> { const tokens = await this.getTokens(); return { isAuthenticated: await this.hasValidTokens(), expiresAt: tokens?.expiresAt, supabaseUserId: tokens?.supabaseUserId, tokenPath: this.tokensPath }; } }