@ordino.ai/cli
Version:
ordino.ai global command line interface
636 lines (590 loc) • 23.3 kB
text/typescript
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 `
<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(`
<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(`
<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();