UNPKG

@ordino.ai/cli

Version:
636 lines (590 loc) 23.3 kB
import express, { Request, Response } from 'express'; import { Server } from 'http'; import open from 'open'; import { printMessage } from '../utils/printMessage.util'; import { serviceFactory } from '../config/service-factory'; import { getCognitoConfig, getPortalConfig } from '../config/cognito.config'; interface AuthCallbackData { code?: string; state?: string; error?: string; } interface AuthTokens { access_token: string; id_token: string; refresh_token: string; token_type: string; expires_in: number; } export class CognitoAuthService { private server: Server | null = null; private app: express.Application; private readonly portRange = { min: 8000, max: 8010 }; private getUserFriendlyErrorMessage(error: any): string { const errorMessage = error?.message || error?.toString() || 'Unknown error'; if (errorMessage.includes('fetch failed') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND') || errorMessage.includes('ETIMEDOUT')) { return 'Unable to connect to the authentication server. Please check your internet connection and try again.'; } if (errorMessage.includes('access_denied') || errorMessage.includes('user_cancelled')) { return 'Authentication was cancelled or denied. Please try signing in again.'; } if (errorMessage.includes('invalid_grant') || errorMessage.includes('invalid_request')) { return 'The authentication request was invalid. Please try signing in again.'; } if (errorMessage.includes('server_error') || errorMessage.includes('temporarily_unavailable')) { return 'The authentication service is temporarily unavailable. Please try again in a few moments.'; } if (errorMessage.includes('Social sign-in failed')) { return 'Unable to complete social sign-in. Please try again or contact support if the issue persists.'; } if (errorMessage.includes('Token exchange failed')) { return 'Unable to complete authentication. Please try signing in again.'; } if (errorMessage.includes('Failed to') || errorMessage.includes('Error:') || errorMessage.includes('Exception:')) { return 'An unexpected error occurred during authentication. Please try again.'; } return errorMessage; } private generateErrorPageHTML(errorMessage: string): string { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Error - Ordino CLI</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .container { background: white; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); padding: 48px; text-align: center; max-width: 500px; width: 100%; animation: slideUp 0.5s ease-out; } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } h1 { color: #1a202c; font-size: 28px; font-weight: 700; margin-bottom: 16px; } .error-message { background: #fff5f5; border-left: 4px solid #f5576c; padding: 16px; border-radius: 8px; margin: 20px 0; color: #c53030; font-size: 14px; } </style> </head> <body> <div class="container"> <h1>Authentication Error</h1> <p style="color: #4a5568; margin-bottom: 16px;">Something went wrong during authentication</p> <div class="error-message"> ${errorMessage} </div> <p style="color: #718096; font-size: 14px; margin-top: 20px;"> You can close this window and try again from the CLI </p> </div> <script> setTimeout(() => { window.close(); }, 5000); </script> </body> </html> `; } private async isPortAvailable(port: number): Promise<boolean> { return new Promise((resolve) => { try { const net = require('net'); const testServer = net.createServer(); testServer.listen(port, 'localhost', () => { testServer.close(() => resolve(true)); }); testServer.on('error', (error: any) => { resolve(false); }); } catch (error) { resolve(false); } }); } private currentPort: number | null = null; private authCompletePromise: Promise<AuthTokens | null> | null = null; private authCompleteResolver: ((value: AuthTokens | null) => void) | null = null; constructor() { this.app = express(); this.setupRoutes(); } private setupRoutes(): void { this.app.get('/callback', (req: Request, res: Response) => { const { code, state, error } = req.query as AuthCallbackData; if (error) { const userFriendlyError = this.getUserFriendlyErrorMessage({ message: error }); res.send(this.generateErrorPageHTML(userFriendlyError)); if (this.authCompleteResolver) { this.authCompleteResolver(null); } return; } if (code) { this.exchangeCodeForTokens(code) .then((tokens) => { res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Successful - Ordino CLI</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .container { background: white; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); padding: 48px; text-align: center; max-width: 500px; width: 100%; animation: slideUp 0.5s ease-out; } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } h1 { color: #1a202c; font-size: 28px; font-weight: 700; margin-bottom: 16px; } .message { color: #4a5568; font-size: 16px; line-height: 1.6; margin-bottom: 24px; } .cli-message { background: #f7fafc; border-left: 4px solid #667eea; padding: 16px; border-radius: 8px; margin-bottom: 24px; } .cli-message p { color: #2d3748; font-size: 15px; font-weight: 500; } </style> </head> <body> <div class="container"> <h1>Authentication Successful!</h1> <div class="message"> Your authentication has been completed successfully. </div> <div class="cli-message"> <p>🎉 You're all set!</p> <p style="margin-top: 8px; font-weight: 400; font-size: 14px;"> You can now close this window and return to the CLI </p> </div> </div> <script> setTimeout(() => { window.close(); }, 3000); </script> </body> </html> `); if (this.authCompleteResolver) { this.authCompleteResolver(tokens); } }) .catch((error) => { const userFriendlyError = this.getUserFriendlyErrorMessage(error); res.send(this.generateErrorPageHTML(userFriendlyError)); if (this.authCompleteResolver) { this.authCompleteResolver(null); } }); } else { res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Authentication Failed - Ordino CLI</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; } .container { background: white; border-radius: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); padding: 48px; text-align: center; max-width: 500px; width: 100%; animation: slideUp 0.5s ease-out; } @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } h1 { color: #1a202c; font-size: 28px; font-weight: 700; margin-bottom: 16px; } </style> </head> <body> <div class="container"> <h1>Authentication Failed</h1> <p style="color: #4a5568; margin: 16px 0;">No authorization code was received from the authentication provider.</p> <p style="color: #718096; font-size: 14px;"> Please try signing in again from the CLI </p> </div> <script> setTimeout(() => { window.close(); }, 3000); </script> </body> </html> `); if (this.authCompleteResolver) { this.authCompleteResolver(null); } } }); this.app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', message: 'Auth server is running' }); }); } private async exchangeCodeForTokens(code: string): Promise<AuthTokens | null> { try { const currentEnvironment = serviceFactory.getEnvironment(); const cognitoConfig = getCognitoConfig(currentEnvironment); if (!code || code.length < 10) { throw new Error('Invalid authorization code received'); } const redirectUri = this.currentPort ? `http://localhost:${this.currentPort}/callback` : cognitoConfig.redirectUri; const requestBody = new URLSearchParams({ grant_type: 'authorization_code', client_id: cognitoConfig.clientId, code: code, redirect_uri: redirectUri, }); if (cognitoConfig.clientSecret) { requestBody.append('client_secret', cognitoConfig.clientSecret); } const response = await fetch(cognitoConfig.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: requestBody, }); if (!response.ok) { const errorText = await response.text(); try { const errorData = JSON.parse(errorText); const errorMessage = errorData.error || errorText; throw new Error(`Token exchange failed: ${errorMessage}`); } catch { throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`); } } const tokens: AuthTokens = await response.json(); return tokens; } catch (error) { throw error; } } private startServer(): Promise<void> { return new Promise(async (resolve, reject) => { try { let availablePort: number | null = null; for (let port = this.portRange.min; port <= this.portRange.max; port++) { if (await this.isPortAvailable(port)) { availablePort = port; break; } } if (!availablePort) { reject(new Error(`No available ports found in range ${this.portRange.min}-${this.portRange.max}. Please close other applications using these ports.`)); return; } this.server = this.app.listen(availablePort, 'localhost', () => { this.currentPort = availablePort; resolve(); }); this.server.on('error', (error: any) => { reject(error); }); } catch (error) { reject(error); } }); } private stopServer(): Promise<void> { return new Promise((resolve) => { if (this.server) { const timeoutId = setTimeout(() => { this.server = null; this.currentPort = null; resolve(); }, 2000); this.server.close(() => { clearTimeout(timeoutId); this.server = null; this.currentPort = null; resolve(); }); } else { resolve(); } }); } private buildAuthUrl(): string { const currentEnvironment = serviceFactory.getEnvironment(); const cognitoConfig = getCognitoConfig(currentEnvironment); const authEndpoint = `https://${cognitoConfig.domain}/login`; const redirectUri = this.currentPort ? `http://localhost:${this.currentPort}/callback` : cognitoConfig.redirectUri; const params = new URLSearchParams({ client_id: cognitoConfig.clientId, response_type: 'code', scope: 'email openid phone profile', redirect_uri: redirectUri, state: Math.random().toString(36).substring(2, 15), }); return `${authEndpoint}?${params.toString()}`; } public async authenticateWithBrowser(): Promise<AuthTokens | null> { try { await this.startServer(); this.authCompletePromise = new Promise((resolve) => { this.authCompleteResolver = resolve; }); const authUrl = this.buildAuthUrl(); printMessage('🌐 Opening browser for authentication...', "36", false, false); printMessage(`If the browser doesn't open automatically, please visit: ${authUrl}`, "33", false, false); try { await open(authUrl); } catch (error) { printMessage('Failed to open browser automatically. Please copy and paste the URL above into your browser.', "31", false, false); } const timeoutPromise = new Promise<AuthTokens | null>((resolve) => { setTimeout(() => { printMessage('⏰ Authentication timeout after 5 minutes', "31", false, false); resolve(null); }, 5 * 60 * 1000); }); const result = await Promise.race([this.authCompletePromise, timeoutPromise]); return result; } catch (error) { const userFriendlyError = this.getUserFriendlyErrorMessage(error); printMessage(`❌ Authentication error: ${userFriendlyError}`, "31", false, false); return null; } finally { await this.stopServer(); this.authCompletePromise = null; this.authCompleteResolver = null; } } public async checkAuthStatus(): Promise<boolean> { return false; } private isOAuthLogin(idToken: string): boolean { try { const tokenParts = idToken.split('.'); if (tokenParts.length === 3) { const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); return payload.identities && Array.isArray(payload.identities) && payload.identities.length > 0; } return false; } catch { return true; } } private async getUserInfo(accessToken: string): Promise<any> { const currentEnvironment = serviceFactory.getEnvironment(); const cognitoConfig = getCognitoConfig(currentEnvironment); const response = await fetch(`https://${cognitoConfig.domain}/oauth2/userInfo`, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { throw new Error('Failed to fetch user info'); } return await response.json(); } private async socialSignIn(userInfo: any, idToken: string): Promise<string | null> { const currentEnvironment = serviceFactory.getEnvironment(); const portalConfig = getPortalConfig(currentEnvironment); const socialSignInData = { Email: userInfo.email, FirstName: userInfo.given_name || userInfo.name || 'User', LastName: userInfo.family_name || userInfo.given_name || 'User', }; const response = await fetch(`${portalConfig.baseUrl}/api/v1/social-signin`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${idToken}` }, body: JSON.stringify(socialSignInData) }); if (!response.ok) { const errorText = await response.text(); const technicalError = `Social sign-in failed: ${response.status} ${response.statusText} - ${errorText}`; const userFriendlyError = this.getUserFriendlyErrorMessage({ message: technicalError }); throw new Error(userFriendlyError); } const result = await response.json(); if (result.isSuccess && result.extraInfo) { return typeof result.extraInfo === 'string' ? result.extraInfo : result.extraInfo.idToken; } else { throw new Error(result.message || 'Failed to get backend token'); } } private async getCliApiKey(token: string): Promise<string | null> { const currentEnvironment = serviceFactory.getEnvironment(); const portalConfig = getPortalConfig(currentEnvironment); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { const response = await fetch(`${portalConfig.baseUrl}/api/v1/system-api-key/cli-key`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to generate CLI API key: ${response.status} ${response.statusText} - ${errorText}`); } const result = await response.json(); if (result.isSuccess && result.extraInfo) { return typeof result.extraInfo === 'string' ? result.extraInfo : result.extraInfo.accessToken; } else { throw new Error(result.message || 'Failed to generate CLI API key'); } } catch (error) { clearTimeout(timeoutId); if (error instanceof Error) { if (error.message.includes('ECONNREFUSED') || error.message.includes('fetch failed') || error.message.includes('aborted')) { return null; } } throw error; } } public async authenticate(): Promise<AuthTokens | null> { const isAuthenticated = await this.checkAuthStatus(); if (isAuthenticated) { printMessage('✅ Already authenticated', "32", false, false); return null; } const tokens = await this.authenticateWithBrowser(); if (tokens) { printMessage('✅ Authentication successful!', "32", false, false); try { const isOAuthLogin = this.isOAuthLogin(tokens.id_token); if (isOAuthLogin) { const userInfo = await this.getUserInfo(tokens.access_token); if (userInfo) { const backendToken = await this.socialSignIn(userInfo, tokens.id_token); if (backendToken) { const cliApiKey = await this.getCliApiKey(backendToken); if (cliApiKey) { (tokens as any).cliApiKey = cliApiKey; } } } } else { const cliApiKey = await this.getCliApiKey(tokens.id_token); if (cliApiKey) { (tokens as any).cliApiKey = cliApiKey; } } } catch (error) { const userFriendlyError = this.getUserFriendlyErrorMessage(error); printMessage(`⚠️ Warning: ${userFriendlyError}`, "33", false, false); } return tokens; } else { printMessage('❌ Authentication failed or was cancelled', "31", false, false); return null; } } } export const cognitoAuthService = new CognitoAuthService();